diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6db2b71 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +# Keep the build context lean and secrets out of the image. +.git +.gitignore +**/__pycache__ +**/*.pyc +**/*.pyo +**/*.pyd +.pytest_cache +.claude +logs +*.log +docker/.env +# data/ and config/ ARE included so the named volumes auto-populate defaults +# (arm-motion JSONL, typed-replay audio, face/zone galleries) on first run. diff --git a/.gitignore b/.gitignore index c02264b..5afbb3c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ __pycache__/ *.pyc Logs/ *.log + +# Docker runtime secret +docker/.env diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 0000000..f13300a --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,136 @@ +# SanadV3 — Feature Catalog + +Sanad is a bilingual (Arabic/English) humanoid receptionist/assistant on a +Unitree G1 (Jetson Orin NX, ROS 2 Foxy, Livox MID-360). This catalogs +**what's built today** (Part A) and **what's on the roadmap** (Part B). + +--- + +# Part A — Current features (built & running) + +Verified from the live subsystem registry (19 subsystems), dashboard tabs +(12), and API routers (22). + +## 1. Voice & Conversation +- **Gemini live voice** — real-time bilingual AR/EN spoken conversation (native-audio model) +- **Offline brain** — local pipeline via `ollama` (`SANAD_VOICE_BRAIN=local`), no cloud +- **Wake phrases** — configurable wake-word manager +- **Typed replay** — type text, robot speaks it (with speaker-monitor capture) +- **Local TTS** — on-device text-to-speech engine +- **Prompt management** — edit the system prompt from the dashboard +- **Lip-sync** — mask mouth driven by TTS `MOUTH` markers +- **Barge-in** — interrupt speech (volume-scaled threshold) + +## 2. Motion & Arm +- **35 arm actions** — 16 SDK built-ins + 19 custom JSONL motions +- **Macro record / playback** — capture and replay motion sequences +- **Teaching mode** — kinesthetic teach-and-repeat +- **Skills** — composed higher-level behaviors (skills.json) +- **Movement dispatch** — voice → motion (53 fixed + 10 parametric phrases, cooldown-gated) +- **Arm motion-block** — auto-inhibits arm moves while locomotion is active (safety interlock) + +## 3. Locomotion +- **LocoClient + MotionSwitcher** — walk / pose control via Unitree SDK (eth0) +- **E-STOP** — dashboard kill button +- **Single Ctrl+C teardown** — one signal cleanly stops every subsystem (~2s) + +## 4. LED Face Mask +- **Animated expressions** — neutral, smile, blink, look L/R, talk1–3, surprised, sad +- **Gestural-speaking events** — face reacts while speaking +- **Lip-sync** — mouth animates to speech + +## 5. Vision & Recognition +- **Face recognition** — identify people via camera +- **Face gallery** — enroll/manage known faces +- **Zone gallery / zones** — visual zone recognition +- **Camera feed** — attached to the live voice subprocess (vision-in-the-loop) + +## 6. Navigation (web_nav3 integration) +- **Live Map tab** — full embedded web_nav3 dashboard (set-pose, goals, bringup) +- **Navigation tab** — native canvas viewer (saved/live map, places, missions) +- **map_relay** — re-publishes the latched `/map` @1Hz so the map renders even when stationary +- **Saved maps** — load & view a pre-built `.db` (localize mode) +- **Places** — save named poses, one-click "Go" +- **Missions** — multi-waypoint routes (defined in web_nav3) +- **Cancel goal** — stop an active goal without tearing down bringup +- **SLAM** — RTABMap LiDAR-ICP, drift-corrected mapping/localization + +## 7. Audio +- **Device manager** — sink/source selection, live refresh +- **Audio profiles** — builtin / anker / hollyland_builtin (auto-switch on plug/unplug) + +## 8. Operations, System & Diagnostics +- **System control** — start/stop subsystems, status +- **Temperature monitor** — motor temps (live websocket stream) +- **Controller** — gamepad/teleop input +- **Web terminal** — shell in the browser (websocket) +- **Logs** — live log stream +- **Recordings & replay** — record/playback sessions +- **Scripts** — run saved scripts + +## Dashboard infrastructure +- 12 tabs, **fault-isolated routers** (one broken module never breaks the dashboard) +- WebSocket streams: log_stream, motor_temps, terminal +- No-store HTML (no stale-cache 404s after deploy) +- Lazy subsystem imports (missing dep → that subsystem unavailable, rest runs) + +--- + +# Part B — Roadmap (to add) + +Tiers = priority. 🏗️ = load-bearing · ⚠️ = Foxy constraint. + +## Tier 1 — Autonomous behaviors (the product) +1. **Voice-driven navigation** — "Sanad, go to the lobby" → nav goal +2. **Greeter mission** — recognized face → navigate → greet → express +3. **Named-person greeting** — identity → personalized line +4. **Patrol / guided tours** — ordered places, speech at each stop +5. **Return-to-base / dock-on-idle** — auto-home on idle/low battery + +## Tier 2 — Navigation & map (harden + edit) +6. 🏗️ **Map republish relay** — ✅ DONE (map_relay) +7. **Click-to-goal on Nav tab canvas** +8. **Live nav telemetry** — distance/ETA/waypoint, "arrived" toast +9. **Battery + nav-state status bar** +10. **Geofence zones on the map** +11. **Cancel-goal button** — ✅ DONE +### Map editing & annotation (all build on #6) +12. **Erase tool** — paint cells free; wipe ghost obstacles + the SLAM "spokes" +13. **Obstacle paint ("black points" / virtual walls)** — ⚠️ Foxy-safe KeepoutFilter substitute +14. **Shape tools + brush size** — line/rectangle/polygon +15. **Non-destructive overlay + undo/redo** +16. **Persist & auto-reload edits per map** +17. **Crop / trim map bounds** + +## Tier 3 — Voice & interaction +18. **Barge-in from dashboard** +19. **Quick-phrase soundboard** +20. **Conversation memory / visitor log** +21. **Per-speaker AR/EN auto-detect** +22. **Scheduled announcements** +23. **Bake edited map → PGM/YAML** (static map_server deploy) + +## Tier 4 — Face & presence +24. **Gaze / head-track recognized face** +25. **Emotion-from-context** (sentiment → expression) +26. **Idle breathing / look-around** +27. **Lip-sync to TTS amplitude** (enhance existing markers) + +## Tier 5 — Operator, fleet & reliability +28. 🏗️ **Global E-STOP button** — ✅ exists; surface consistently +29. **Health watchdog** — auto-restart dead subsystem + alert +30. **Per-subsystem enable/disable toggles** +31. **Behavior recorder → replay** (nav+voice timelines) +32. **Mission editor UI** (visual sequence builder) +33. **Remote access / tunnel** +34. **Reverse-proxy web_nav3 through :8001** — one origin, no iframe cross-port issues + +## Tier 6 — Future / blocked +35. **Speed / caution zones** — needs Galactic SpeedFilter or custom layer +36. **Multi-robot fleet** (SanadV3 ↔ BotBrain) — needs LocoClient arbitration + coordinator + +--- + +### Recommended next build order +**#1 voice→nav** → **#2 greeter mission** (the product), then **#12/#13 map editing** +(clean the spokes + virtual walls). #6 republish relay and #11 cancel are already done. diff --git a/G1_Controller/loco_controller.py b/G1_Controller/loco_controller.py index f2746db..22ba59d 100644 --- a/G1_Controller/loco_controller.py +++ b/G1_Controller/loco_controller.py @@ -255,12 +255,19 @@ class LocoController: period = max(0.02, min(0.1, self._wd_timeout / 2.0)) while not self._wd_stop.is_set(): fire = False + park = False # Read-and-decide under the lock (atomic check-then-act); the actual # StopMove runs after release so the critical section stays tiny. with self._lock: if self._teleop_active and (time.monotonic() - self._last_move_ts) > self._wd_timeout: self._teleop_active = False fire = True + # Self-park once there's nothing left to guard. The Gemini + # dispatch path uses step() directly and never calls + # disarm_movement(), so without this the watchdog would spin for + # the rest of the process lifetime after the first voice step. + if not self._armed and not self._teleop_active and not self._discrete_busy: + park = True if fire: log.warning("watchdog: teleop setpoint stale (>%.2fs) — StopMove", self._wd_timeout) @@ -268,6 +275,12 @@ class LocoController: self._raw_stop() except Exception: log.exception("watchdog StopMove failed") + if park: + # Nothing left to guard — stop the thread (a later move/step + # re-arms it via _start_watchdog()). Done AFTER any stale-stop + # above so we never skip a pending StopMove. + self._wd_stop.set() + break self._wd_stop.wait(period) def _raw_stop(self) -> bool: @@ -560,8 +573,21 @@ class LocoController: # ── shutdown helper ────────────────────────────────────────────────────── def shutdown(self): - """Best-effort StopMove + disarm for process shutdown.""" + """Best-effort StopMove + disarm for process shutdown. + + Uses _raw_stop() (NOT estop()) so teardown never builds a brand-new + LocoClient: estop() → _ensure_client() would lazily construct a client + and run bot.Init() (a DDS RPC) during interpreter teardown when we were + armed-but-never-built (Enable movement clicked, never moved, then + Ctrl+C). _raw_stop() no-ops when no client was ever created. Bump the + stop generation so any in-flight motion bails immediately.""" + with self._lock: + self._stop_gen += 1 + self._teleop_active = False + self._cur_v = (0.0, 0.0, 0.0) try: - self.estop() + self._raw_stop() # no-op when _bot is None — never re-inits + except Exception: + log.exception("StopMove on shutdown failed") finally: self.disarm_movement() diff --git a/config.py b/config.py index a25c673..47375df 100644 --- a/config.py +++ b/config.py @@ -271,7 +271,9 @@ def _resolve_dashboard_host() -> str: DASHBOARD_HOST = _resolve_dashboard_host() -DASHBOARD_PORT = 8000 +# Canonical SanadV3 port (matches shell_scripts/start_all.sh + docs). The +# legacy Sanad ran on :8000; SanadV3 is :8001 to never collide with it. +DASHBOARD_PORT = 8001 # -- Local TTS -- LOCAL_TTS_MODEL = "MBZUAI/speecht5_tts_clartts_ar" @@ -365,6 +367,11 @@ LIVE_TUNE: dict[str, str] = { CAMERA_SERVICE_PORT = 8091 DIRECT_CAMERA_URL = f"http://127.0.0.1:{CAMERA_SERVICE_PORT}" +# -- Navigation (web_nav3 / rosbridge) -- +WEB_NAV3_URL = os.environ.get("WEB_NAV3_URL", "http://127.0.0.1:8765") +ROSBRIDGE_URL = os.environ.get("ROSBRIDGE_URL", "ws://127.0.0.1:9090") +NAV_ROBOT_NAME = os.environ.get("NAV_ROBOT_NAME", "sanad") + # -- DDS / hardware -- # Jetson G1 default is eth0 (the robot's internal network). # Override with SANAD_DDS_INTERFACE=lo for desktop/sim development. diff --git a/core/persona.py b/core/persona.py new file mode 100644 index 0000000..ef0810c --- /dev/null +++ b/core/persona.py @@ -0,0 +1,71 @@ +"""Active-persona selection — which script file Gemini loads as its system +prompt. + +The operator can keep several persona variants in scripts/ (e.g. +``sanad_script.txt``, ``sanad_script_v1.txt``, ``sanad_script_v2.txt``) and pick +which one is live. The selection is a single basename stored in +``data/active_persona.txt``; the DEFAULT (and reset target) is always the +configured persona (``sanad_script.txt``). The Gemini child resolves this at +session start, so a new selection takes effect on the next voice (re)connect. + +A missing/blank/stale pointer transparently falls back to the default, so this +can never break the voice — worst case it loads ``sanad_script.txt``. +""" +from __future__ import annotations + +from pathlib import Path + +from Project.Sanad.config import DATA_DIR, SCRIPTS_DIR + +ACTIVE_PERSONA_FILE = DATA_DIR / "active_persona.txt" + + +def default_persona_name() -> str: + """The configured default persona filename (core.script_files.persona).""" + try: + from Project.Sanad.core.config_loader import section as _section + name = (_section("core", "script_files") or {}).get("persona") + return (name or "sanad_script.txt").strip() or "sanad_script.txt" + except Exception: + return "sanad_script.txt" + + +def active_persona_name() -> str: + """Selected persona basename — the chosen variant if set AND still exists, + otherwise the default. Never raises.""" + default = default_persona_name() + try: + sel = ACTIVE_PERSONA_FILE.read_text(encoding="utf-8").strip() + except Exception: + sel = "" + if sel: + cand = SCRIPTS_DIR / Path(sel).name # basename only — no traversal + if cand.is_file(): + return cand.name + return default + + +def active_persona_path() -> Path: + """Full path to the persona script Gemini should load right now.""" + return SCRIPTS_DIR / active_persona_name() + + +def set_active_persona(name: str | None) -> str: + """Persist the selected persona basename. Passing None/"" or the default + name clears the pointer (revert to default). Returns the effective active + name. Raises FileNotFoundError if a non-default name doesn't exist.""" + nm = (Path(str(name)).name if name else "").strip() + default = default_persona_name() + if not nm or nm == default: + try: + ACTIVE_PERSONA_FILE.unlink() + except FileNotFoundError: + pass + except Exception: + pass + return default + if not (SCRIPTS_DIR / nm).is_file(): + raise FileNotFoundError(nm) + DATA_DIR.mkdir(parents=True, exist_ok=True) + ACTIVE_PERSONA_FILE.write_text(nm, encoding="utf-8") + return nm diff --git a/dashboard/app.py b/dashboard/app.py index 8261aab..f9d68e1 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -54,6 +54,7 @@ _REST_ROUTES: list[tuple[str, str, str]] = [ ("zones", "/api/zones", "zones"), ("temp_monitor", "/api/temp", "temperature"), ("controller", "/api/controller", "controller"), + ("navigation", "/api/nav", "navigation"), ] _WS_ROUTES: list[str] = ["log_stream", "motor_temps", "terminal"] @@ -113,7 +114,13 @@ async def root(): if index.exists(): from fastapi.responses import HTMLResponse try: - return HTMLResponse(index.read_text(encoding="utf-8")) + # no-store so the browser always re-fetches the dashboard HTML/JS + # after a deploy — otherwise stale cached JS keeps calling old + # endpoints (e.g. /nav/* instead of /api/nav/*) and 404s. + return HTMLResponse( + index.read_text(encoding="utf-8"), + headers={"Cache-Control": "no-store, must-revalidate"}, + ) except OSError as exc: return {"error": f"Could not read index.html: {exc}"} return { diff --git a/dashboard/routes/_arbiter.py b/dashboard/routes/_arbiter.py new file mode 100644 index 0000000..8d263da --- /dev/null +++ b/dashboard/routes/_arbiter.py @@ -0,0 +1,66 @@ +"""In-process arbitration between Nav2 (web_nav3) and the manual LocoController. + +Both stacks can drive the G1's legs via different command paths: + - Nav2 (web_nav3) publishes cmd_vel from a navigation goal/mission. + - LocoController issues LocoClient.Move()/step() from the Controller tab and + Gemini movement dispatch. + +The documented hazard is "two stacks must never both drive the legs at once". +This module is a tiny thread-safe gate that lets ONE commander own the legs at a +time. controller.py sets loco_active for arm/move/step and refuses when nav is +active; navigation.py sets nav_active for goto/missions/run and refuses when loco +is active. The E-STOP / cancel paths clear the relevant flag. + +Pure in-process state (no DDS, no HTTP) — both routers share this single module +instance, so the flags are coherent across the dashboard process. +""" + +from __future__ import annotations + +import threading + +_lock = threading.Lock() +_loco_active = False +_nav_active = False + + +def loco_active() -> bool: + with _lock: + return _loco_active + + +def nav_active() -> bool: + with _lock: + return _nav_active + + +def acquire_loco() -> bool: + """Claim the legs for manual loco. Returns False if Nav2 holds them.""" + global _loco_active + with _lock: + if _nav_active: + return False + _loco_active = True + return True + + +def release_loco() -> None: + global _loco_active + with _lock: + _loco_active = False + + +def acquire_nav() -> bool: + """Claim the legs for Nav2. Returns False if manual loco holds them.""" + global _nav_active + with _lock: + if _loco_active: + return False + _nav_active = True + return True + + +def release_nav() -> None: + global _nav_active + with _lock: + _nav_active = False diff --git a/dashboard/routes/audio_control.py b/dashboard/routes/audio_control.py index 6ed28f1..fa9d299 100644 --- a/dashboard/routes/audio_control.py +++ b/dashboard/routes/audio_control.py @@ -160,7 +160,13 @@ async def audio_status(): "g1_speaker_muted": g1_muted, "g1_current_volume": _g1_current_volume, "g1_user_volume": _g1_user_volume, - "g1_available": _g1_audio_client is not None or (_g1_init_error == ""), + # Only report available once an AudioClient has actually been + # built — reporting True before any init attempt made the UI + # advertise G1 speaker controls that then 503 on first use. + # `g1_init_error` surfaces *why* it's unavailable (or "" if + # init was never attempted yet). + "g1_available": _g1_audio_client is not None, + "g1_init_error": _g1_init_error, "sink": sink, "source": source, "current": cur, @@ -312,7 +318,9 @@ async def get_g1_volume(): """ def _do(): return { - "available": _g1_audio_client is not None or (_g1_init_error == ""), + # True only after an AudioClient was actually constructed — + # `init_error` (below) explains an unavailable/never-tried state. + "available": _g1_audio_client is not None, "current_volume": _g1_current_volume, "user_volume": _g1_user_volume, "muted": _g1_current_volume == 0, @@ -343,35 +351,48 @@ async def set_g1_volume(payload: G1VolumePayload): if not 0 <= level <= 100: raise HTTPException(400, "level must be 0..100") + # 1) G1 chest speaker (DDS) — best-effort so it works even when an + # external sink (JBL) is the active output. + code = None client = _get_g1_audio_client() - if client is None: - raise HTTPException( - 503, - f"G1 AudioClient unavailable: {_g1_init_error or 'unknown'}", - ) - try: - with _g1_audio_lock: - code = client.SetVolume(level) - _g1_current_volume = level - if level > 0: - # Only update the "preferred unmuted" level when the - # user is setting a non-zero volume. Setting 0 is a - # mute, which shouldn't overwrite their preference. - _g1_user_volume = level - except Exception as exc: - raise HTTPException(500, f"SetVolume failed: {exc}") + if client is not None: + try: + with _g1_audio_lock: + code = client.SetVolume(level) + _g1_current_volume = level + except Exception as exc: + log.warning("G1 SetVolume failed: %s", exc) + if level > 0: + _g1_user_volume = level + + # 2) The ACTIVE profile's PulseAudio sink (JBL / Anker / …). Target the + # RESOLVED sink from the saved selection, NOT @DEFAULT_SINK@ — the PA + # default can be a different sink (e.g. the chest platform-sound) even + # when the JBL is the selected output, so @DEFAULT_SINK@ would move the + # wrong sink and the slider would appear to do nothing on the JBL. + pa_applied = False + try: + sink = (ad.load_state() or {}).get("sink") or "@DEFAULT_SINK@" + _pactl(["set-sink-volume", sink, "%d%%" % level]) + if level > 0: + _pactl(["set-sink-mute", sink, "0"]) + pa_applied = True + except Exception as exc: + log.warning("PA set-sink-volume failed: %s", exc) + + if client is None and not pa_applied: + raise HTTPException(503, "No speaker available (G1 + PulseAudio both failed)") - # Persist the user's preferred level (not the current) so a - # subsequent mute-then-restart restores to the preferred level _save_persisted_g1_volume(_g1_user_volume) - log.info("G1 volume → %d (user_pref=%d, rc=%s)", - level, _g1_user_volume, code) + log.info("volume → %d (g1_rc=%s, pa=%s, user_pref=%d)", + level, code, pa_applied, _g1_user_volume) return { "ok": True, "current_volume": level, "user_volume": _g1_user_volume, "muted": level == 0, "return_code": code, + "pa_applied": pa_applied, "persisted": True, } return await asyncio.to_thread(_do) @@ -471,6 +492,28 @@ async def apply_audio(): audio_mgr.refresh_devices() except Exception: pass + # Hot-swap the live Gemini voice to the selected profile too, so picking + # a device (e.g. the JBL) moves BOTH record playback AND the live voice + # to it — without dropping the session. Best-effort; no-op if not running. + try: + from Project.Sanad.main import live_sub + pid = (ad.load_state() or {}).get("profile_id") + if (pid and live_sub is not None and hasattr(live_sub, "send_profile") + and hasattr(live_sub, "is_running") and live_sub.is_running()): + live_sub.send_profile(pid, reason="dashboard audio Apply") + except Exception: + pass + # Restore the user's SAVED volume to the selected sink (USB/BT speakers + # like the JBL otherwise come back at a low PulseAudio default). Use the + # saved level, NOT a forced 100%, so the slider/sink keep the user's + # choice across selects + restarts. Target the resolved sink. + try: + sink = (ad.load_state() or {}).get("sink") or "@DEFAULT_SINK@" + _pactl(["set-sink-volume", sink, "%d%%" % _g1_user_volume]) + if _g1_user_volume > 0: + _pactl(["set-sink-mute", sink, "0"]) + except Exception: + pass return result return await asyncio.to_thread(_do) @@ -584,6 +627,37 @@ def _kill_audio_daemon(flavour: str) -> dict: "stderr": "timeout (>5s)"} +def _remote_pulse_mode() -> bool: + """True when this process talks to ANOTHER machine-scope PulseAudio — the + containerized deployment runs as root with PULSE_SERVER pointed at the + host's socket. Restarting that daemon is not ours to do.""" + return bool(os.environ.get("PULSE_SERVER")) or os.path.exists("/.dockerenv") + + +def _cycle_remote_pulse() -> dict: + """Suspend→resume every sink/source on the remote (host) PulseAudio. A + suspend cycle forces the ALSA device rebind and clears wedged streams — + the useful part of a daemon restart, minus the restart.""" + cycled: list[str] = [] + for kind, flag in (("sinks", "suspend-sink"), ("sources", "suspend-source")): + try: + r = subprocess.run(["pactl", "list", "short", kind], check=False, + capture_output=True, text=True, timeout=5.0) + names = [ln.split("\t")[1] for ln in (r.stdout or "").splitlines() + if "\t" in ln] + except (FileNotFoundError, subprocess.SubprocessError): + names = [] + for name in names: + if name.endswith(".monitor"): + continue + for state in ("1", "0"): + subprocess.run(["pactl", flag, name, state], check=False, + capture_output=True, text=True, timeout=5.0) + cycled.append(name) + return {"cmd": "remote pulse suspend/resume cycle", "returncode": 0, + "stderr": "", "cycled": cycled} + + def _wait_for_pactl(deadline_s: float = 5.0, interval_s: float = 0.2) -> bool: """Poll `pactl info` until it returns 0 or the deadline expires.""" import time as _time @@ -603,7 +677,8 @@ async def reset_audio_subsystem(): is being selected. **Does NOT recover a kernel-side missing USB capture descriptor** — for that symptom use /api/audio/usb-reset. """ - if os.geteuid() == 0: + remote_pulse = _remote_pulse_mode() + if os.geteuid() == 0 and not remote_pulse: raise HTTPException( 403, "Refusing to reset audio as root — Sanad must run as the " "unitree user so the per-user PulseAudio session is reachable.", @@ -644,9 +719,16 @@ async def reset_audio_subsystem(): if acquired and play_lock is not None: play_lock.release() - flavour = _detect_pa_flavour() - kill_info = _kill_audio_daemon(flavour) - came_back = _wait_for_pactl(deadline_s=5.0) + if remote_pulse: + # Containerized: the daemon is the HOST's — never kill it. + # Cycle its devices over the socket instead. + flavour = "pulse-remote" + kill_info = _cycle_remote_pulse() + came_back = _wait_for_pactl(deadline_s=5.0) + else: + flavour = _detect_pa_flavour() + kill_info = _kill_audio_daemon(flavour) + came_back = _wait_for_pactl(deadline_s=5.0) if not came_back and flavour == "pulse": # autospawn may be disabled — try an explicit start. try: diff --git a/dashboard/routes/controller.py b/dashboard/routes/controller.py index f0b998d..1c55e13 100644 --- a/dashboard/routes/controller.py +++ b/dashboard/routes/controller.py @@ -22,6 +22,8 @@ from Project.Sanad.config import BASE_DIR from Project.Sanad.core.logger import get_logger from Project.Sanad.vision import recognition_state +from Project.Sanad.dashboard.routes import _arbiter + log = get_logger("controller_routes") router = APIRouter() @@ -75,6 +77,31 @@ def _require_armed(lc): raise HTTPException(409, "Movement is disarmed. Enable movement first.") +def _claim_loco(): + """Arbitration gate: refuse a leg command while a Nav2 goal owns the legs.""" + if not _arbiter.acquire_loco(): + raise HTTPException( + 409, "Navigation (Nav2) is active. Cancel the nav goal before manual movement." + ) + + +def _cancel_nav(): + """Cancel any in-flight Nav2 goal and clear the nav arbitration flag. + + Used by E-STOP so the global stop halts the legs no matter which stack is + driving them. Calls the nav client in-process (no HTTP self-call); never + raises into the caller. + """ + try: + from Project.Sanad.dashboard.routes.navigation import _CLIENT as _nav_client + if _nav_client is not None: + _nav_client.cancel() + except Exception: + log.exception("estop nav cancel failed") + finally: + _arbiter.release_nav() + + # ── reads ─────────────────────────────────────────────────── @router.get("/status") @@ -100,7 +127,17 @@ async def get_msc(): @router.post("/arm") async def set_arm(on: bool = Query(...)): lc = _require_loco() - res = await asyncio.to_thread(lc.arm_movement if on else lc.disarm_movement) + if on: + # Refuse to arm manual loco while Nav2 owns the legs. + _claim_loco() + try: + res = await asyncio.to_thread(lc.arm_movement) + except Exception: + _arbiter.release_loco() + raise + return res + res = await asyncio.to_thread(lc.disarm_movement) + _arbiter.release_loco() return res @@ -144,6 +181,10 @@ async def estop(): md.emergency_stop() except Exception: log.exception("estop dispatcher latch failed") + # Cancel any in-flight Nav2 goal too: the legs have exactly one commander, + # and an E-STOP must halt the legs whether loco or Nav2 is driving them. + await asyncio.to_thread(_cancel_nav) + _arbiter.release_loco() return {"ok": True, **res} @@ -168,6 +209,7 @@ class MoveBody(BaseModel): async def move(body: MoveBody): lc = _require_loco() _require_armed(lc) + _claim_loco() return await asyncio.to_thread(lc.move, body.vx, body.vy, body.vyaw, body.run) @@ -175,6 +217,7 @@ async def move(body: MoveBody): async def step(dir: str = Query(...)): lc = _require_loco() _require_armed(lc) + _claim_loco() res = await asyncio.to_thread(lc.step, dir) if not res.get("ok"): raise HTTPException(400, res.get("reason", "step failed")) diff --git a/dashboard/routes/live_subprocess.py b/dashboard/routes/live_subprocess.py index 831789c..43e1610 100644 --- a/dashboard/routes/live_subprocess.py +++ b/dashboard/routes/live_subprocess.py @@ -4,10 +4,15 @@ from __future__ import annotations import asyncio -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query + +from Project.Sanad.config import BASE_DIR +from Project.Sanad.vision import recognition_state router = APIRouter() +_STATE_PATH = BASE_DIR / "data" / ".recognition_state.json" + def _sub_or_503(): from Project.Sanad.main import live_sub @@ -19,9 +24,21 @@ def _sub_or_503(): @router.get("/status") async def subprocess_status(): from Project.Sanad.main import live_sub + # record_enabled is a live flag (recognition_state) the panel toggle drives; + # surface it so the UI shows the current state even before a session starts. + rec = bool(recognition_state.read(_STATE_PATH).record_enabled) if live_sub is None: - return {"available": False, "state": "unavailable"} - return live_sub.status() + return {"available": False, "state": "unavailable", "record_enabled": rec} + return {**live_sub.status(), "record_enabled": rec} + + +@router.post("/record") +async def set_record(on: bool = Query(...)): + """Toggle auto-recording of conversation turns to data/recordings/. Takes + effect live (the voice child syncs its recorder) — no session restart.""" + st = await asyncio.to_thread( + recognition_state.mutate, _STATE_PATH, record_enabled=bool(on)) + return {"ok": True, "record_enabled": st.record_enabled} @router.post("/start") diff --git a/dashboard/routes/navigation.py b/dashboard/routes/navigation.py new file mode 100644 index 0000000..0a2ef8b --- /dev/null +++ b/dashboard/routes/navigation.py @@ -0,0 +1,402 @@ +"""Navigation tab — proxy to the web_nav3 Nav2 stack. + +Routes live under /api/nav (the prefix is applied centrally in dashboard/app.py, +NOT here). This router is a thin HTTP proxy: it forwards dashboard requests to a +single module-level WebNav3Client, which itself talks to the standalone web_nav3 +FastAPI service (default http://127.0.0.1:8765 + rosbridge on :9090). + +Fault isolation, two layers: + 1. The `from ...navigation import WebNav3Client` import is GUARDED. If the + navigation package can't be imported (missing dep, syntax error), this + module still imports cleanly — `_CLIENT` is None and every handler degrades + (GET /status returns {"available": False}; actions raise 503). This mirrors + how app.py loads each router in isolation. + 2. WebNav3Client never raises into us by contract — every method returns a + clean dict / NavStatus even when web_nav3 is unreachable — so handlers just + forward the result. Blocking HTTP calls run off the event loop. +""" + +from __future__ import annotations + +import asyncio + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel + +from Project.Sanad.core.logger import get_logger + +from Project.Sanad.dashboard.routes import _arbiter + +log = get_logger("navigation_route") + +# Module-level router with NO prefix and NO tags — those are supplied by +# app.include_router(prefix="/api/nav", tags=["navigation"]) at registration time. +router = APIRouter() + + +# ── guarded optional import ───────────────────────────────── +# A broken navigation package must NOT stop this route module from importing — +# app.py would otherwise log the whole router as failed. Guard it and degrade. +try: + from Project.Sanad.navigation import WebNav3Client # type: ignore + _IMPORT_ERROR: str | None = None +except Exception as exc: # noqa: BLE001 + WebNav3Client = None # type: ignore[assignment,misc] + _IMPORT_ERROR = f"{type(exc).__name__}: {exc}" + log.warning("navigation client unavailable — nav routes degraded: %s", _IMPORT_ERROR) + + +# ── config (env var -> dashboard config section -> default) ── +def _nav_config() -> dict: + """Resolve nav connection config. Precedence: env var -> config -> default.""" + import os + + from Project.Sanad.core.config_loader import section as _cfg_section + + cfg = _cfg_section("dashboard", "navigation") + web_nav3_url = ( + os.environ.get("WEB_NAV3_URL") + or cfg.get("web_nav3_url") + or "http://127.0.0.1:8765" + ) + rosbridge_url = ( + os.environ.get("ROSBRIDGE_URL") + or cfg.get("rosbridge_url") + or "ws://127.0.0.1:9090" + ) + robot = os.environ.get("SANAD_ROBOT_NAME") or cfg.get("robot") or "sanad" + return { + "web_nav3_url": str(web_nav3_url), + "rosbridge_url": str(rosbridge_url), + "robot": str(robot), + } + + +_CFG = _nav_config() + +# ── single module-level client ────────────────────────────── +# One WebNav3Client for the whole dashboard, built from config. If the import +# was guarded out (above), or construction fails, _CLIENT stays None and every +# handler degrades gracefully. +if WebNav3Client is not None: + try: + _CLIENT = WebNav3Client(base_url=_CFG["web_nav3_url"], robot=_CFG["robot"]) + log.info("WebNav3Client ready → %s (robot=%s)", _CFG["web_nav3_url"], _CFG["robot"]) + except Exception as exc: # noqa: BLE001 + _CLIENT = None + _IMPORT_ERROR = f"construct failed: {type(exc).__name__}: {exc}" + log.warning("WebNav3Client construction failed — nav routes degraded: %s", exc) +else: + _CLIENT = None + + +def _require(): + """Return the live client or raise 503 (for ACTION endpoints).""" + if _CLIENT is None: + raise HTTPException(503, f"Navigation client unavailable. {_IMPORT_ERROR or ''}".strip()) + return _CLIENT + + +def _claim_nav(): + """Arbitration gate: refuse to start a Nav2 goal while manual loco owns legs.""" + if not _arbiter.acquire_nav(): + raise HTTPException( + 409, "Manual movement (Controller) is armed. Disarm it before navigating." + ) + + +# ── request bodies ────────────────────────────────────────── +class _NameBody(BaseModel): + name: str + + +class _IdBody(BaseModel): + id: object # mission ids may be int or str; forward as-is + + +class _StartBody(BaseModel): + mode: int = 2 # web_nav3 launch mode (e.g. 3 = localize against a saved map) + db_path: str | None = None # saved map to load (None = build fresh) + + +class _PoseBody(BaseModel): + name: str + x: float + y: float + yaw: float = 0.0 + + +class _RenameBody(BaseModel): + old: str + new: str + + +# ── status (never raises — degraded body when unavailable) ── +@router.get("/status") +async def status(): + if _CLIENT is None: + return {"available": False, "error": _IMPORT_ERROR} + nav = await asyncio.to_thread(_CLIENT.status) + # WebNav3Client.status() returns a NavStatus dataclass. + body = nav.as_dict() if hasattr(nav, "as_dict") else dict(nav) + body["available"] = True + return body + + +# ── places / navigation ───────────────────────────────────── +@router.get("/places") +async def places(map_name: str | None = Query(None, alias="map")): + """List saved places. Per-MAP when ?map= is given (each map keeps + its own places); else the legacy per-robot store.""" + client = _require() + return await asyncio.to_thread(client.list_places, map_name) + + +@router.post("/goto") +async def goto(body: _NameBody): + client = _require() + _claim_nav() + res = await asyncio.to_thread(client.goto, body.name) + # A failed dispatch never drove the legs — release the gate so manual loco + # isn't locked out by a goto that never started. + if isinstance(res, dict) and not res.get("ok", True): + _arbiter.release_nav() + return res + + +@router.post("/start") +async def start(body: _StartBody): + client = _require() + return await asyncio.to_thread(client.start, body.mode, body.db_path) + + +class _DbBody(BaseModel): + db_path: str + + +@router.post("/load_map") +async def load_map(body: _DbBody): + """View a saved map: stop any running bringup, then localize against it.""" + client = _require() + return await asyncio.to_thread(client.load_map, body.db_path) + + +@router.post("/cancel") +async def cancel(): + client = _require() + res = await asyncio.to_thread(client.cancel) + # WebNav3Client.cancel() is a no-op server-side (it only returns a note), + # so releasing the arbiter without truly stopping Nav2 would let the robot + # keep driving while manual loco re-acquires the legs (double-drive). Send a + # REAL goal-cancel over rosbridge first, and disarm the arrival monitor so a + # stale terminal can't fire, THEN release. + try: + from Project.Sanad.navigation.goal_monitor import request_cancel, disarm + disarm() + cancelled = await asyncio.to_thread(request_cancel) + if isinstance(res, dict): + res = {**res, "cancel_sent": bool(cancelled)} + except Exception as exc: # noqa: BLE001 + log.debug("goal cancel skipped: %s", exc) + _arbiter.release_nav() + return res + + +@router.post("/save_here") +async def save_here(body: _NameBody): + client = _require() + return await asyncio.to_thread(client.save_here, body.name) + + +@router.post("/save_at") +async def save_at(body: _PoseBody, map_name: str | None = Query(None, alias="map")): + """Save a named place at a map coordinate (from clicking the map). Per-MAP + when ?map= given. Re-saving an existing name MOVES the place.""" + client = _require() + return await asyncio.to_thread(client.save_at, body.name, body.x, body.y, body.yaw, map_name) + + +@router.post("/places/delete") +async def delete_place(body: _NameBody, map_name: str | None = Query(None, alias="map")): + """Delete a saved place (per-map).""" + client = _require() + return await asyncio.to_thread(client.delete_place, body.name, map_name) + + +@router.post("/places/rename") +async def rename_place(body: _RenameBody, map_name: str | None = Query(None, alias="map")): + """Rename a saved place (per-map).""" + client = _require() + return await asyncio.to_thread(client.rename_place, body.old, body.new, map_name) + + +class _MapEditsBody(BaseModel): + edits: list # [[world_x, world_y, value], ...] value 0=free/erase, 100=wall + + +@router.get("/map_edits") +async def get_map_edits(map_name: str = Query(..., alias="map")): + """Saved edit overlay for a map (erased points + painted walls).""" + client = _require() + return await asyncio.to_thread(client.get_map_edits, map_name) + + +@router.post("/map_edits") +async def save_map_edits(body: _MapEditsBody, map_name: str = Query(..., alias="map")): + """Persist a map's edit overlay (Map Editor).""" + client = _require() + return await asyncio.to_thread(client.save_map_edits, map_name, body.edits) + + +class _VoiceGotoBody(BaseModel): + place: str + + +def _resolve_place(client, spoken: str) -> dict: + """Resolve a spoken place name against the ACTIVE map's places. + + Strategy: exact (case-insensitive) → single substring candidate → + ambiguous / unknown. Returns a dict the caller (and ultimately Gemini) + can act on. Never raises. + """ + try: + st = client.status() + body = st.as_dict() if hasattr(st, "as_dict") else dict(st) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "reason": "status_error", "detail": str(exc)[:160]} + if not body.get("bringup_alive"): + return {"ok": False, "reason": "no_map", + "detail": "No navigation session is running — load a map first."} + active_map = body.get("active_map") + try: + places = client.list_places(active_map) or [] + except Exception: # noqa: BLE001 + places = [] + names = [p.get("name") for p in places if isinstance(p, dict) and p.get("name")] + sl = (spoken or "").strip().lower() + if not sl: + return {"ok": False, "reason": "no_place", "map": active_map, "places": names} + exact = [n for n in names if n.lower() == sl] + if exact: + return {"ok": True, "resolved": exact[0], "map": active_map} + subs = [] + for n in names: + nl = n.lower() + if sl in nl or nl in sl: + subs.append(n) + subs = list(dict.fromkeys(subs)) # de-dup, preserve order + if len(subs) == 1: + return {"ok": True, "resolved": subs[0], "map": active_map} + if len(subs) > 1: + return {"ok": False, "reason": "ambiguous", "candidates": subs, "map": active_map} + return {"ok": False, "reason": "unknown_place", "candidates": names, "map": active_map} + + +@router.get("/active") +async def active(): + """Navigation context for Gemini: the active map, its mode, and that map's + place names — one call so the voice tools (list_places / where_am_i) don't + have to guess the active map.""" + client = _require() + st = await asyncio.to_thread(client.status) + body = st.as_dict() if hasattr(st, "as_dict") else dict(st) + places = [] + if body.get("bringup_alive"): + try: + pl = await asyncio.to_thread(client.list_places, body.get("active_map")) + places = [p.get("name") for p in (pl or []) + if isinstance(p, dict) and p.get("name")] + except Exception: # noqa: BLE001 + places = [] + return { + "map": body.get("active_map"), + "mode": body.get("mode"), + "mode_label": body.get("mode_label"), + "localizing": bool(body.get("localizing")), + "bringup_alive": bool(body.get("bringup_alive")), + "places": places, + } + + +@router.post("/voice_goto") +async def voice_goto(body: _VoiceGotoBody): + """Resolve a spoken place name and drive there — Gemini's navigate_to_place. + + Arbiter-gated (claims the legs for Nav2) and arms the arrival monitor so + Gemini later hears [NAV ARRIVED]/[NAV FAILED]. Never raises into the caller; + returns a structured result the model can speak from. + """ + client = _require() + res = await asyncio.to_thread(_resolve_place, client, body.place or "") + if not res.get("ok"): + return res + # Claim the legs for Nav2 — refuse (don't raise) if manual loco is armed. + if not _arbiter.acquire_nav(): + return {"ok": False, "reason": "manual_armed", + "detail": "Manual movement (Controller) is armed — disarm it to navigate."} + drive = await asyncio.to_thread(client.goto, res["resolved"]) + if isinstance(drive, dict) and not drive.get("ok", True): + _arbiter.release_nav() + return {"ok": False, "reason": "dispatch_failed", + "resolved": res["resolved"], "detail": drive} + # Arm arrival monitoring (best-effort; absence must not fail the drive). + try: + from Project.Sanad.navigation.goal_monitor import arm_goal + arm_goal(res["resolved"]) + except Exception as exc: # noqa: BLE001 + log.debug("goal monitor arm skipped: %s", exc) + return {"ok": True, "resolved": res["resolved"], "map": res.get("map")} + + +@router.post("/goto_pose") +async def goto_pose(body: _PoseBody): + """Arbiter-gate a coordinate nav goal (click-to-drive). + + The browser publishes the actual /goal_pose over rosbridge; this only + CLAIMS the legs for Nav2 (409 if manual loco is armed) so the two stacks + never both drive. The frontend sends the goal only after this returns ok. + """ + _require() + _claim_nav() + # Arm the arrival monitor so this click-to-drive goal releases the arbiter + # when it ends — without this, nav_active stays True forever after the goal + # completes (the browser publishes the goal but never arms anything). + try: + from Project.Sanad.navigation.goal_monitor import arm_goal + arm_goal(f"({body.x:.1f}, {body.y:.1f})") + except Exception as exc: # noqa: BLE001 + log.debug("goal monitor arm skipped: %s", exc) + return {"ok": True, "x": body.x, "y": body.y, "yaw": body.yaw} + + +# ── maps / missions ───────────────────────────────────────── +@router.get("/maps") +async def maps(): + client = _require() + return await asyncio.to_thread(client.list_maps) + + +@router.get("/missions") +async def missions(): + client = _require() + return await asyncio.to_thread(client.list_missions) + + +@router.post("/missions/run") +async def run_mission(body: _IdBody): + client = _require() + _claim_nav() + res = await asyncio.to_thread(client.run_mission, body.id) + if isinstance(res, dict) and not res.get("ok", True): + _arbiter.release_nav() + return res + + +# ── config (what the SPA needs to render links / connect) ─── +@router.get("/config") +async def config(): + return { + "web_nav3_url": _CFG["web_nav3_url"], + "rosbridge_url": _CFG["rosbridge_url"], + "robot": _CFG["robot"], + } diff --git a/dashboard/routes/recognition.py b/dashboard/routes/recognition.py index b1f9d60..2e73698 100644 --- a/dashboard/routes/recognition.py +++ b/dashboard/routes/recognition.py @@ -12,6 +12,7 @@ child polls that file at 1 Hz and applies changes mid-session. from __future__ import annotations +import asyncio import io from typing import Optional @@ -124,6 +125,23 @@ async def set_vision(on: bool = Query(...)): "camera": cam.status()} +@router.post("/stereo-stream") +async def set_stereo_stream(on: bool = Query(...)): + """Start/stop the R1 head stereo video service (phone-free) via the robot_state + DDS call — the same thing the Unitree app's Settings → Service Status does. + Video only; never touches motion. Vision-ON also auto-starts it, so this is + for explicit control (e.g. to stop the stream and free the robot's encoder).""" + try: + from Project.Sanad.vision.stream_service import get_stream_service + ok = get_stream_service().set_stereo(on) + except Exception as exc: + raise HTTPException(503, f"stereo stream control unavailable: {exc}") + if not ok: + raise HTTPException(502, "robot_state ServiceSwitch failed (robot link / DDS?)") + log.info("stereo stream %s via dashboard", "started" if on else "stopped") + return {"ok": True, "stereo_on": on} + + @router.post("/face-rec") async def set_face_rec(on: bool = Query(...)): """Enable / disable face recognition (hot — no Gemini restart). @@ -166,6 +184,63 @@ async def latest_frame(): ) +@router.get("/stream.mjpg") +async def mjpeg_stream(): + """Continuous MJPEG (multipart/x-mixed-replace) of the live camera. + + The dashboard preview points an at this instead of polling + /frame.jpg every 500 ms — frames are pushed as soon as the daemon + encodes them (at the capture FPS) over a single kept-alive connection, + which removes the stop-motion lag of the old poll. Each consumer just + reads the daemon's cached JPEG, so N viewers cost no extra capture. + The generator ends when the camera stops, the feed goes stale (client + can reconnect), or the client disconnects (FastAPI cancels the task). + """ + cam = _get_camera() + if cam is None: + raise HTTPException(503, "Camera subsystem unavailable.") + + async def gen(): + boundary = b"--frame\r\n" + last_seq = -1 + idle_ticks = 0 + # ~1 s at 20 ms/tick. While the camera is running but no NEW frame has + # arrived (a slow reconnect / stalled or frozen source can hold the + # feed for >15 s), re-send the last cached frame as a keep-alive. This + # is the key fix: a graceful server-side EOF on a multipart does + # NOT reliably fire onerror, so the browser would freeze on the last + # frame with no way to re-attach. We only ever end the stream when the + # camera actually stops (frontend then detaches via status) or the + # client disconnects (the yield/await is cancelled — no task leak). + heartbeat_ticks = 50 + while True: + if not cam.is_running(): + break + seq = cam.frame_seq + fresh = (seq != last_seq) + if fresh or idle_ticks >= heartbeat_ticks: + jpeg = cam.snapshot_jpeg() + if jpeg is not None: + last_seq = seq + idle_ticks = 0 + yield (boundary + + b"Content-Type: image/jpeg\r\nContent-Length: " + + str(len(jpeg)).encode() + b"\r\n\r\n" + + jpeg + b"\r\n") + # new frames pace at the capture FPS; heartbeats at ~1 s + await asyncio.sleep(0.005 if fresh else 0.02) + continue + idle_ticks += 1 + await asyncio.sleep(0.02) + + return StreamingResponse( + gen(), + media_type="multipart/x-mixed-replace; boundary=frame", + headers={"Cache-Control": "no-store, must-revalidate, private", + "Pragma": "no-cache"}, + ) + + # ── camera resolution / quality ───────────────────────────── class CameraConfigPayload(BaseModel): @@ -173,6 +248,13 @@ class CameraConfigPayload(BaseModel): height: Optional[int] = None fps: Optional[int] = None jpeg_quality: Optional[int] = None + r1_port: Optional[int] = None # R1 head eye: 5001 front / 5002 left / 5003 right + dewarp: Optional[bool] = None # fisheye de-warp (hot, no rebuild) + + +# R1 head video source ports that carry a decodable H.264 eye. Depth (5000) +# is not a viewable colour stream, so it is intentionally excluded. +_R1_SOURCE_PORTS = {5001, 5002, 5003} @router.post("/camera-config") @@ -194,9 +276,12 @@ async def set_camera_config(payload: CameraConfigPayload): raise HTTPException(400, "fps out of range (1–60)") if payload.jpeg_quality is not None and not (10 <= payload.jpeg_quality <= 95): raise HTTPException(400, "jpeg_quality out of range (10–95)") + if payload.r1_port is not None and payload.r1_port not in _R1_SOURCE_PORTS: + raise HTTPException(400, f"r1_port must be one of {sorted(_R1_SOURCE_PORTS)}") profile = cam.reconfigure( width=payload.width, height=payload.height, fps=payload.fps, jpeg_quality=payload.jpeg_quality, + r1_port=payload.r1_port, dewarp=payload.dewarp, ) log.info("camera reconfigured via dashboard → %s", profile) return {"ok": True, "profile": profile, "camera": cam.status()} diff --git a/dashboard/routes/records.py b/dashboard/routes/records.py index 8846e6d..f9212c1 100644 --- a/dashboard/routes/records.py +++ b/dashboard/routes/records.py @@ -23,6 +23,12 @@ router = APIRouter() RECORDS_INDEX = AUDIO_RECORDINGS_DIR / "records.json" _INDEX_LOCK = threading.Lock() +# Strong refs to fire-and-forget playback tasks. The event loop only keeps a +# weak reference to tasks, so an unreferenced create_task() result can be +# garbage-collected (cancelling playback) before it finishes. Mirror replay.py. +import asyncio as _asyncio # noqa: E402 +_BG_TASKS: set[_asyncio.Task] = set() + def _load_index() -> dict[str, Any]: if not RECORDS_INDEX.exists(): @@ -110,15 +116,19 @@ async def play_record(payload: RecordPlay): raise HTTPException(404, f"File not found: {raw_path.name}") from Project.Sanad.main import audio_mgr - import asyncio - # Fire-and-forget — play_wav blocks for the clip duration on the G1 - # DDS path, and the dashboard's pause / resume / stop / status calls - # need to be served while it's running. Without this, /play wouldn't - # return until the clip finished and the UI couldn't interact with - # the in-flight playback. - asyncio.create_task(asyncio.to_thread( - audio_mgr.play_wav, raw_path, payload.record_name, - )) + import threading + # Fire-and-forget on a DEDICATED daemon thread — NOT asyncio.to_thread. + # to_thread runs on the shared default executor, which gets starved while + # the dashboard services the live-voice child's reconnect chatter; that + # delayed record playback by several seconds (clip silent, counter parked). + # A dedicated thread starts immediately regardless of executor/event-loop + # load. play_wav blocks for the clip duration and serves pause/stop via + # _play_state; the UI stays responsive because this handler returns now. + # Python keeps running threads alive, so no ref is needed to prevent GC. + threading.Thread( + target=audio_mgr.play_wav, args=(raw_path, payload.record_name), + name="record-playback", daemon=True, + ).start() return {"ok": True, "record_name": payload.record_name, "file_kind": payload.file_kind, "path": str(raw_path)} @@ -135,6 +145,14 @@ async def resume_playback(): return audio_mgr.resume_playback() +@router.post("/seek") +async def seek_playback(position_sec: float): + """Jump to a position (seconds) in the currently-playing clip — used by the + waveform scrubber. No-op (ok=False) if nothing is playing.""" + from Project.Sanad.main import audio_mgr + return audio_mgr.seek_playback(position_sec) + + @router.post("/stop") async def stop_playback(): from Project.Sanad.main import audio_mgr @@ -149,6 +167,15 @@ async def playback_status(): return audio_mgr.playback_status() +@router.post("/live-hold") +async def set_live_hold(on: bool): + """Manual hold for the live-Gemini pause. on=True pauses the live voice and + keeps it paused (records won't resume it) until on=False is sent. Default + behaviour (on=False) is AUTO: records pause Gemini only for the clip.""" + from Project.Sanad.main import audio_mgr + return {"live_hold": audio_mgr.set_live_voice_hold(on)} + + class RecordRename(BaseModel): record_name: str new_name: str @@ -217,7 +244,12 @@ async def delete_record(payload: RecordDelete): deleted_files = [] for fi in deleted_entry.get("files", {}).values(): try: - p = Path(fi.get("path", "")).resolve() + # _resolve_path handles new-style basenames (resolved under + # AUDIO_RECORDINGS_DIR) as well as legacy absolute paths. + # A raw Path(basename) would resolve vs CWD and fall outside + # base, so the relative_to guard would skip the unlink and the + # WAV would be orphaned on disk. Mirror play_record/rename_record. + p = _resolve_path(fi.get("path", "")).resolve() p.relative_to(base) # never delete files outside recordings dir except (ValueError, OSError): continue @@ -228,3 +260,43 @@ async def delete_record(payload: RecordDelete): index["records"] = kept _save_index(index) return {"ok": True, "deleted": payload.record_name, "deleted_files": deleted_files} + + +class RecordBulkDelete(BaseModel): + record_names: list[str] | None = None + all: bool = False + + +@router.post("/delete-bulk") +async def delete_bulk(payload: RecordBulkDelete): + """Delete many records in one call. all=True wipes every record; otherwise + only those in record_names. Files are unlinked, guarded to the recordings + dir (same safety as /delete).""" + names = set(payload.record_names or []) + with _INDEX_LOCK: + index = _load_index() + base = AUDIO_RECORDINGS_DIR.resolve() + kept: list = [] + removed: list = [] + deleted_files = 0 + for r in index.get("records", []): + if payload.all or r.get("record_name") in names: + removed.append(r.get("record_name")) + for fi in r.get("files", {}).values(): + try: + p = _resolve_path(fi.get("path", "")).resolve() + p.relative_to(base) # never delete outside recordings dir + except (ValueError, OSError): + continue + if p.exists(): + try: + p.unlink() + deleted_files += 1 + except OSError: + pass + else: + kept.append(r) + index["records"] = kept + _save_index(index) + return {"ok": True, "deleted": removed, "deleted_count": len(removed), + "deleted_files": deleted_files} diff --git a/dashboard/routes/scripts.py b/dashboard/routes/scripts.py index b4c65a5..f4f18f2 100644 --- a/dashboard/routes/scripts.py +++ b/dashboard/routes/scripts.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from datetime import datetime from pathlib import Path @@ -9,6 +10,7 @@ from fastapi import APIRouter, HTTPException from pydantic import BaseModel from Project.Sanad.config import SCRIPTS_DIR +from Project.Sanad.core import persona as _persona from Project.Sanad.dashboard.routes._safe_io import ( atomic_write_text, MAX_UPLOAD_BYTES, ) @@ -31,6 +33,8 @@ def _safe_path(name: str) -> Path: @router.get("/") async def list_scripts(): SCRIPTS_DIR.mkdir(parents=True, exist_ok=True) + active = _persona.active_persona_name() + default = _persona.default_persona_name() items = [] for p in sorted(SCRIPTS_DIR.iterdir(), key=lambda x: x.name.lower()): if not p.is_file(): @@ -40,8 +44,48 @@ async def list_scripts(): "name": p.name, "size_bytes": st.st_size, "modified_at": datetime.fromtimestamp(st.st_mtime).isoformat(timespec="seconds"), + "active": p.name == active, # the persona Gemini loads now + "is_default": p.name == default, # the fallback (sanad_script.txt) }) - return {"path": str(SCRIPTS_DIR), "files": items} + return {"path": str(SCRIPTS_DIR), "files": items, + "active": active, "default": default} + + +class ScriptActive(BaseModel): + name: str | None = None # None / "" / the default name → revert to default + restart: bool = False # also restart the live voice so it takes effect now + + +@router.get("/active") +async def get_active(): + """Which persona Gemini will load, and the default it falls back to.""" + return {"active": _persona.active_persona_name(), + "default": _persona.default_persona_name()} + + +@router.post("/active") +async def set_active(payload: ScriptActive): + """Select the persona script Gemini uses. With restart=true, the live voice + session is bounced so the new persona takes effect immediately; otherwise it + applies on the next voice (re)connect.""" + try: + active = _persona.set_active_persona(payload.name) + except FileNotFoundError: + raise HTTPException(404, f"Script not found: {payload.name}") + restarted = False + if payload.restart: + try: + from Project.Sanad.main import live_sub + if live_sub is not None and hasattr(live_sub, "start"): + if hasattr(live_sub, "is_running") and live_sub.is_running(): + await asyncio.to_thread(live_sub.stop) + await asyncio.sleep(1.5) + await asyncio.to_thread(live_sub.start) + restarted = True + except Exception: + pass # selection is saved regardless of restart success + return {"ok": True, "active": active, + "default": _persona.default_persona_name(), "restarted": restarted} class ScriptLoad(BaseModel): @@ -116,5 +160,9 @@ async def delete_script(payload: ScriptDelete): path = _safe_path(payload.name) if not path.exists(): raise HTTPException(404, f"Not found: {payload.name}") + if path.name == _persona.default_persona_name(): + raise HTTPException(409, f"Cannot delete the default persona ({path.name}).") path.unlink() + # If the active selection was the deleted file, resolution auto-falls-back + # to the default — no extra cleanup needed. return {"ok": True, "deleted": payload.name} diff --git a/dashboard/routes/system.py b/dashboard/routes/system.py index 4839579..aba03f4 100644 --- a/dashboard/routes/system.py +++ b/dashboard/routes/system.py @@ -5,18 +5,24 @@ from __future__ import annotations import asyncio import os import platform +import shutil import socket import sys +from pathlib import Path from typing import Any -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel from Project.Sanad.config import ( + AUDIO_RECORDINGS_DIR, BASE_DIR, DASHBOARD_HOST, DASHBOARD_INTERFACE, DASHBOARD_PORT, + DATA_DIR, DDS_NETWORK_INTERFACE, + LOGS_DIR, list_network_interfaces, ) from Project.Sanad.core.logger import get_logger @@ -26,6 +32,36 @@ log = get_logger("system_route") router = APIRouter() +def _runtime_bind() -> tuple[str, int]: + """The host/port the server is ACTUALLY bound to. + + main.py launches `uvicorn.run(_app, host=args.host, port=args.port)` with + the CLI --host/--port (start_sanad.sh passes `--port $PORT`, default 8001), + which can differ from the import-time DASHBOARD_HOST/DASHBOARD_PORT config + defaults (port 8000). Reading the live argv reports the real URL instead of + a stale config value. Falls back to the config constants when an arg is + absent (e.g. argparse default in effect).""" + host = DASHBOARD_HOST + port = DASHBOARD_PORT + argv = sys.argv + for i, tok in enumerate(argv): + if tok == "--host" and i + 1 < len(argv): + host = argv[i + 1] + elif tok.startswith("--host="): + host = tok.split("=", 1)[1] + elif tok == "--port" and i + 1 < len(argv): + try: + port = int(argv[i + 1]) + except (TypeError, ValueError): + pass + elif tok.startswith("--port="): + try: + port = int(tok.split("=", 1)[1]) + except (TypeError, ValueError): + pass + return host, port + + def _safe_status(component, name: str) -> dict[str, Any]: if component is None: return {"available": False} @@ -90,8 +126,9 @@ async def system_info(): except Exception: interfaces = [] - # Determine the URL the dashboard is reachable at - bound_host = DASHBOARD_HOST + # Determine the URL the dashboard is reachable at — use the ACTUAL + # runtime bind args (argv), not the import-time config defaults. + bound_host, bound_port = _runtime_bind() if bound_host == "0.0.0.0": # Try to find the wlan0 IP for display purposes up_ifaces = [i for i in interfaces if i["is_up"] and i["ip"] and not i["ip"].startswith("127.")] @@ -112,8 +149,8 @@ async def system_info(): "interface": DASHBOARD_INTERFACE, "bound_host": bound_host, "display_host": display_host, - "port": DASHBOARD_PORT, - "url": f"http://{display_host}:{DASHBOARD_PORT}", + "port": bound_port, + "url": f"http://{display_host}:{bound_port}", }, "dds": { "interface": DDS_NETWORK_INTERFACE, @@ -131,3 +168,148 @@ async def system_info(): } return await asyncio.to_thread(_do) + + +# ───────────────────── storage tracking + cleanup ───────────────────── +# Categories surfaced in the Settings → Storage panel. `cleanable` ones get a +# Clean button + are included in "Clean all"; the rest (faces/motions/zones) +# are shown for tracking only — they're operational assets (enrollments, +# motion configs) managed in their own tabs, not disposable clutter. +_STORAGE_CATS = [ + ("recordings", "Conversation recordings", DATA_DIR / "recordings", True), + ("records", "Named records (Typed Replay)", AUDIO_RECORDINGS_DIR, True), + ("logs", "Logs", LOGS_DIR, True), + ("faces", "Enrolled faces", DATA_DIR / "faces", False), + ("motions", "Motion replays + config", DATA_DIR / "motions", False), + ("photos", "Photos", DATA_DIR / "photos", False), + ("zones", "Vision zones", DATA_DIR / "zones", False), +] +_CLEANABLE = {k for k, _l, _p, c in _STORAGE_CATS if c} + + +def _dir_stats(path: Path) -> tuple[int, int]: + """(total_bytes, file_count) of a dir tree. Missing dir → (0, 0).""" + total, n = 0, 0 + try: + for root, _dirs, files in os.walk(path): + for f in files: + try: + total += os.path.getsize(os.path.join(root, f)) + n += 1 + except OSError: + pass + except Exception: + pass + return total, n + + +def _human(b: float) -> str: + f = float(b) + for u in ("B", "KB", "MB", "GB", "TB"): + if f < 1024 or u == "TB": + return f"{f:.0f} {u}" if u == "B" else f"{f:.1f} {u}" + f /= 1024 + return f"{f:.1f} TB" + + +@router.get("/storage") +async def storage_usage(): + """Per-category data/log sizes + disk free, for the Storage panel.""" + def _do(): + cats = [] + for key, label, path, cleanable in _STORAGE_CATS: + size, files = _dir_stats(Path(path)) + cats.append({ + "key": key, "label": label, "path": str(path), + "size_bytes": size, "size_human": _human(size), + "files": files, "cleanable": cleanable, + }) + data_b, _ = _dir_stats(DATA_DIR) + logs_b, _ = _dir_stats(LOGS_DIR) + try: + du = shutil.disk_usage(str(BASE_DIR)) + disk = { + "free_human": _human(du.free), "total_human": _human(du.total), + "used_pct": round(100.0 * (du.total - du.free) / du.total, 1), + } + except Exception: + disk = {} + return { + "categories": cats, + "data_bytes": data_b, "data_human": _human(data_b), + "logs_human": _human(logs_b), + "total_human": _human(data_b + logs_b), + "disk": disk, + } + return await asyncio.to_thread(_do) + + +class _CleanReq(BaseModel): + target: str # recordings | records | logs | all + + +def _clean_recordings() -> tuple[int, int]: + d = DATA_DIR / "recordings" + freed, n = 0, 0 + for f in list(d.glob("*.wav")) + [d / "index.json"]: + if f.is_file(): + try: + freed += f.stat().st_size + f.unlink() + n += 1 + except OSError: + pass + return n, freed + + +def _clean_records() -> tuple[int, int]: + d = AUDIO_RECORDINGS_DIR + freed, n = 0, 0 + for f in list(d.glob("*.wav")) + [d / "records.json"]: + if f.is_file(): + try: + freed += f.stat().st_size + f.unlink() + n += 1 + except OSError: + pass + return n, freed + + +def _clean_logs() -> tuple[int, int]: + # Truncate (not delete) — active loggers hold append-mode handles, so + # truncating to 0 clears content cleanly without losing the fd. + freed, n = 0, 0 + for f in Path(LOGS_DIR).glob("*.log"): + try: + freed += f.stat().st_size + open(f, "w").close() + n += 1 + except OSError: + pass + return n, freed + + +@router.post("/storage/clean") +async def storage_clean(req: _CleanReq): + """Clean a disposable category (recordings | records | logs) or 'all'. + Recordings/records are deleted; logs are truncated. Assets (faces, motions, + zones) are never touched here.""" + t = (req.target or "").strip().lower() + if t != "all" and t not in _CLEANABLE: + raise HTTPException(400, f"target must be 'all' or one of {sorted(_CLEANABLE)}") + + def _do(): + targets = ["recordings", "records", "logs"] if t == "all" else [t] + fns = {"recordings": _clean_recordings, "records": _clean_records, + "logs": _clean_logs} + result, total = {}, 0 + for tg in targets: + n, freed = fns[tg]() + result[tg] = {"items": n, "freed_bytes": freed, "freed_human": _human(freed)} + total += freed + log.info("storage clean %s → freed %s", targets, _human(total)) + return {"ok": True, "cleaned": targets, + "total_freed_bytes": total, "total_freed_human": _human(total), + "result": result} + return await asyncio.to_thread(_do) diff --git a/dashboard/routes/temp_monitor.py b/dashboard/routes/temp_monitor.py index 266e7b4..81505bb 100644 --- a/dashboard/routes/temp_monitor.py +++ b/dashboard/routes/temp_monitor.py @@ -65,3 +65,17 @@ async def motors_snapshot(): except Exception: positions = [] return build_payload(temps, positions, time.time()) + + +@router.get("/battery") +async def battery_status(): + """Live G1 battery (BMS) snapshot: state-of-charge %, voltage, current, + charge/discharge status, pack temperature, cycles. `available=False` until + the BMS topic (rt/lf/bmsstate) delivers its first message.""" + arm = _get_arm() + if arm is None or not hasattr(arm, "get_battery"): + return {"available": False} + try: + return arm.get_battery() + except Exception: + return {"available": False} diff --git a/dashboard/routes/zones.py b/dashboard/routes/zones.py index 395077b..d6def31 100644 --- a/dashboard/routes/zones.py +++ b/dashboard/routes/zones.py @@ -13,6 +13,7 @@ they just record the target and feed Gemini the place's reference. from __future__ import annotations +import asyncio import io from typing import Optional @@ -110,11 +111,59 @@ def _place_to_dict(p) -> dict: def _zone_to_dict(z) -> dict: return { "id": z.id, "name": z.name, "description": z.description, + "linked_map": getattr(z, "linked_map", None), "added_at": z.added_at, "places": [_place_to_dict(p) for p in z.places], } +async def _maybe_drive_to_place(zone, place) -> Optional[dict]: + """If the place links a nav2 place AND its zone's map is the one currently + localized, actually DRIVE there (arbiter-gated + arm arrival monitor). + Returns the drive outcome, or None when the place isn't drivable (no link). + Best-effort: never raises into the caller.""" + nav_place = getattr(place, "nav_place", None) + linked_map = getattr(zone, "linked_map", None) + if not nav_place or not linked_map: + return None + try: + from Project.Sanad.dashboard.routes import navigation as navmod + from Project.Sanad.dashboard.routes import _arbiter + except Exception: + return {"ok": False, "reason": "nav_unavailable"} + client = getattr(navmod, "_CLIENT", None) + if client is None: + return {"ok": False, "reason": "nav_unavailable"} + try: + st = await asyncio.to_thread(client.status) + body = st.as_dict() if hasattr(st, "as_dict") else dict(st) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "reason": "status_error", "detail": str(exc)[:120]} + if not body.get("bringup_alive"): + return {"ok": False, "reason": "no_map"} + # The robot can only drive in the currently-localized map. Require the + # zone's linked map to match (compare on the sanitized .db stem). + active = (body.get("active_map") or "").strip().lower() + want = (linked_map or "").strip().lower() + if want.endswith(".db"): + want = want[:-3] + if active and want and active != want: + return {"ok": False, "reason": "wrong_map", + "active": body.get("active_map"), "want": linked_map} + if not _arbiter.acquire_nav(): + return {"ok": False, "reason": "manual_armed"} + drive = await asyncio.to_thread(client.goto, nav_place) + if isinstance(drive, dict) and not drive.get("ok", True): + _arbiter.release_nav() + return {"ok": False, "reason": "dispatch_failed", "detail": drive} + try: + from Project.Sanad.navigation.goal_monitor import arm_goal + arm_goal(nav_place) + except Exception: + pass + return {"ok": True, "resolved": nav_place} + + def _nav_target_dict(st, gallery) -> Optional[dict]: zid, pid = st.nav_target_zone_id, st.nav_target_place_id if not zid or not pid: @@ -184,6 +233,16 @@ class FacesPayload(BaseModel): face_ids: list[int] = [] +class LinkMapPayload(BaseModel): + # nav2 map .db basename (e.g. "office.db"); None/"" unlinks. + map: Optional[str] = None + + +class NavPlacePayload(BaseModel): + # nav2 place name in the zone's linked map; None/"" unlinks. + nav_place: Optional[str] = None + + @router.get("") async def list_zones(): g = _require_zones() @@ -247,6 +306,7 @@ async def create_place( name: Optional[str] = Query(default=None), description: Optional[str] = Query(default=None), face_ids: list[int] = Query(default=[]), + nav_place: Optional[str] = Query(default=None), files: Optional[list[UploadFile]] = File(default=None), ): g = _require_zones() @@ -262,11 +322,38 @@ async def create_place( _validate_image(content, f.filename) image_bytes.append(content) p = g.create_place(zone_id, name=name, description=description, - face_ids=face_ids, image_bytes_list=image_bytes or None) + face_ids=face_ids, image_bytes_list=image_bytes or None, + nav_place=nav_place) _bump_zones_version() return {"ok": True, "place": _place_to_dict(p)} +@router.post("/{zone_id}/link_map") +async def link_zone_map(zone_id: int, payload: LinkMapPayload): + """Bind (or unbind) the zone to a nav2 map .db. Required before its places + can link to that map's nav places / before Gemini Nav can drive in it.""" + g = _require_zones() + try: + g.set_zone_map(zone_id, payload.map) + except FileNotFoundError as exc: + raise HTTPException(404, str(exc)) + _bump_zones_version() + return {"ok": True, "zone": _zone_to_dict(g.get_zone(zone_id))} + + +@router.post("/{zone_id}/places/{place_id}/nav_link") +async def link_place_nav(zone_id: int, place_id: int, payload: NavPlacePayload): + """Link (or unlink) a place to a nav2 place name in the zone's map — this is + what makes the place drivable from voice / 'Go here'.""" + g = _require_zones() + try: + g.set_place_nav(zone_id, place_id, payload.nav_place) + except FileNotFoundError as exc: + raise HTTPException(404, str(exc)) + _bump_zones_version() + return {"ok": True, "place": _place_to_dict(g.get_place(zone_id, place_id))} + + @router.post("/{zone_id}/places/{place_id}/rename") async def rename_place(zone_id: int, place_id: int, payload: NamePayload): g = _require_zones() @@ -398,9 +485,13 @@ async def download_place_zip(zone_id: int, place_id: int): @router.post("/{zone_id}/places/{place_id}/go") async def go_to_place(zone_id: int, place_id: int): - """Set this place as the active destination. Records the target and lets - the Gemini child pick it up (reference photo + goal). Actual robot motion - is wired by N2 locomotion — until then this just establishes the goal.""" + """Set this place as the active destination AND, if the place links a nav2 + place in this zone's (currently-localized) map, actually drive there. + + Two effects: (1) records nav_target so the Gemini child primes on the + reference photo + announces the destination; (2) if drivable, dispatches a + Nav2 goal (arbiter-gated, with arrival monitoring). A place with no nav link + is announce-only, as before.""" g = _require_zones() p = g.get_place(zone_id, place_id) if p is None: @@ -410,8 +501,12 @@ async def go_to_place(zone_id: int, place_id: int): nav_target_place_id=place_id) log.info("nav target set → zone_%d/place_%d (%s)", zone_id, place_id, p.name or "(unnamed)") - return {"ok": True, "nav_target": {"zone_id": zone_id, "place_id": place_id, - "place_name": p.name}} + zone = g.get_zone(zone_id) + drive = await _maybe_drive_to_place(zone, p) + return {"ok": True, + "nav_target": {"zone_id": zone_id, "place_id": place_id, + "place_name": p.name}, + "drive": drive} @router.post("/nav/clear") @@ -419,3 +514,84 @@ async def clear_nav_target(): recognition_state.mutate(STATE_PATH, nav_target_zone_id=0, nav_target_place_id=0) log.info("nav target cleared") return {"ok": True, "nav_target": None} + + +def _resolve_map_path(client, linked_map: str) -> Optional[str]: + """Find the .db path for a zone's linked map name via the nav client.""" + want = (linked_map or "").strip().lower() + want_stem = want[:-3] if want.endswith(".db") else want + try: + maps = client.list_maps() or [] + except Exception: + return None + for m in maps: + nm = (m.get("name") or "").strip().lower() + if nm == want or (nm[:-3] if nm.endswith(".db") else nm) == want_stem: + return m.get("path") + return None + + +@router.post("/{zone_id}/gemini_nav/start") +async def gemini_nav_start(zone_id: int): + """Enter 'Gemini Nav' for a zone: localize the zone's map, turn on camera + + face + zone recognition + movement, ensure the Gemini session is live, and + greet the user so they can converse to navigate. + + The robot only ever runs ONE map; this loads the zone's map in localize-only + mode (so it cannot fresh-map while driving), exactly as the user requires. + """ + g = _require_zones() + zone = g.get_zone(zone_id) + if zone is None: + raise HTTPException(404, f"zone_{zone_id} not found") + linked_map = getattr(zone, "linked_map", None) + if not linked_map: + raise HTTPException(400, "This zone has no linked nav2 map — link one first.") + + # 1) Localize the zone's map (single bringup, mode 3 — no fresh mapping). + loaded: dict = {"ok": False, "reason": "nav_unavailable"} + try: + from Project.Sanad.dashboard.routes import navigation as navmod + client = getattr(navmod, "_CLIENT", None) + if client is not None: + db_path = await asyncio.to_thread(_resolve_map_path, client, linked_map) + if db_path: + loaded = await asyncio.to_thread(client.load_map, db_path) + else: + loaded = {"ok": False, "reason": "map_not_found", "map": linked_map} + except Exception as exc: # noqa: BLE001 + loaded = {"ok": False, "reason": "load_error", "detail": str(exc)[:160]} + + # 2) Camera + face + zone recognition + movement ON for the session. + recognition_state.mutate(STATE_PATH, + vision_enabled=True, face_rec_enabled=True, + zone_rec_enabled=True, movement_enabled=True) + _bump_zones_version() + + # 3) Ensure the Gemini session is live, then greet (zone + drivable places). + session_started = False + try: + from Project.Sanad.main import live_sub + if live_sub is not None: + if hasattr(live_sub, "is_running") and not live_sub.is_running(): + await asyncio.to_thread(live_sub.start) + session_started = True + drivable = [p.name or p.nav_place for p in zone.places + if getattr(p, "nav_place", None)] + zname = zone.name or f"zone {zone_id}" + if drivable: + placelist = ", ".join(str(x) for x in drivable) + greet = (f"You are now in the '{zname}' zone. You can drive the " + f"user to: {placelist}. Greet the user warmly in your " + f"normal Khaleeji style and ask where they would like to go.") + else: + greet = (f"You are now in the '{zname}' zone, but no drivable " + f"places are linked to its map yet. Greet the user and " + f"say places still need to be linked before you can drive.") + if hasattr(live_sub, "send_state"): + live_sub.send_state("nav_zone", greet) + except Exception as exc: # noqa: BLE001 + log.warning("gemini_nav greet failed: %s", exc) + + return {"ok": True, "zone_id": zone_id, "zone": _zone_to_dict(zone), + "loaded": loaded, "session_started": session_started} diff --git a/dashboard/static/index.html b/dashboard/static/index.html index 754fac6..dd29511 100644 --- a/dashboard/static/index.html +++ b/dashboard/static/index.html @@ -109,6 +109,121 @@ .switch input:checked+.slider::before{transform:translateX(16px)} /* Scrollbar */ ::-webkit-scrollbar{width:6px} ::-webkit-scrollbar-track{background:transparent} ::-webkit-scrollbar-thumb{background:var(--dim);border-radius:3px} ::-webkit-scrollbar-thumb:hover{background:var(--muted)} + /* WhatsApp-style voice-message record cards */ + .rec-card{display:flex;flex-direction:column;gap:.45rem;padding:.6rem .75rem;margin-bottom:.55rem;background:rgba(255,255,255,.025);border:1px solid var(--border);border-radius:.7rem;transition:background .15s,border-color .15s} + .rec-card.is-playing{background:rgba(52,211,153,.07);border-color:rgba(52,211,153,.4)} + .rec-card .rec-row{display:flex;align-items:center;gap:.6rem} + .rec-sel{width:16px;height:16px;flex:0 0 auto;cursor:pointer} + .rec-play{flex:0 0 auto;width:38px;height:38px;border-radius:50%;border:none;background:var(--accent);color:#04121f;font-size:1rem;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:transform .1s,background .15s} + .rec-play:hover{transform:scale(1.06)} + .rec-card.is-playing .rec-play{background:#34d399} + .rec-replay{flex:0 0 auto;width:26px;height:26px;border-radius:50%;border:1px solid var(--border);background:transparent;color:var(--muted);font-size:.95rem;line-height:1;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:color .12s,border-color .12s} + .rec-replay:hover{color:#34d399;border-color:#34d399} + .rec-wave{flex:1;min-width:70px;display:flex;align-items:center;gap:2px;height:30px;overflow:hidden;cursor:pointer;touch-action:none} + .rec-wave .wb{flex:1 1 0;min-width:2px;max-width:5px;border-radius:2px;background:rgba(255,255,255,.16);transition:background .12s,height .12s;pointer-events:none} + .rec-wave .wb.played{background:#34d399} + .rec-time{flex:0 0 auto;font-size:.7rem;color:var(--dim);font-variant-numeric:tabular-nums;min-width:74px;text-align:right} + .rec-card.is-playing .rec-time{color:#34d399} + .rec-name{font-size:.74rem;font-weight:600;color:var(--muted)} + .rec-text{font-size:.8rem;line-height:1.45;color:var(--fg,#dfe7ef);white-space:pre-wrap;word-break:break-word} + .rec-acts{flex:0 0 auto;display:flex;gap:.3rem;align-items:center} + /* Battery widget (Temperature tab) */ + .batt-icon{position:relative;width:56px;height:27px;border:2px solid var(--muted);border-radius:5px;padding:2px;flex:0 0 auto} + .batt-icon::after{content:'';position:absolute;right:-6px;top:7px;width:4px;height:9px;background:var(--muted);border-radius:0 2px 2px 0} + .batt-fill{height:100%;width:0%;border-radius:2px;background:var(--success);transition:width .45s ease,background .3s} + .batt-fill.charging{animation:battpulse 1.3s ease-in-out infinite} + @keyframes battpulse{0%,100%{opacity:1}50%{opacity:.55}} + /* ── Tablet (single column already kicks in at 900px for .grid) ── */ + @media (max-width:768px){ + header{padding:.6rem .9rem} + .tab-content{padding:.85rem .85rem} + .status-pills{padding:.3rem .9rem} + .tabs{padding:0 .8rem} + } + /* ── Phone view ──────────────────────────────────────────────── */ + @media (max-width:640px){ + /* Header wraps gracefully; controls drop to a second line if needed */ + header{flex-wrap:wrap;gap:.4rem;padding:.5rem .7rem} + header h1{font-size:1.05rem} + .hdr-right{gap:.4rem;font-size:.68rem;flex-wrap:wrap;justify-content:flex-end;width:auto} + #estop{padding:.28rem .6rem;font-size:.66rem} + .hdr-badge{font-size:.6rem;padding:2px 5px} + /* Reclaim horizontal space on the narrow viewport */ + .status-pills{padding:.3rem .55rem;gap:.28rem} + .tabs{padding:0 .45rem;-webkit-overflow-scrolling:touch} + .tab{padding:.5rem .65rem;font-size:.74rem} + .tab-content{padding:.7rem .55rem} + .grid{gap:.55rem} + .card{padding:.8rem .7rem} + .card-full,.card{min-width:0} + /* Wide tables scroll sideways instead of stretching the page */ + .card table{display:block;max-width:100%;overflow-x:auto;white-space:nowrap;-webkit-overflow-scrolling:touch} + /* Generic control rows wrap rather than overflow */ + .row{flex-wrap:wrap} + .toast{max-width:calc(100vw - 24px)} + /* Voice-message record cards: larger touch targets, acts wrap below */ + .rec-card{padding:.55rem .6rem} + .rec-card .rec-row{flex-wrap:wrap;gap:.45rem} + .rec-play{width:44px;height:44px;font-size:1.15rem} + .rec-replay{width:34px;height:34px;font-size:1.05rem} + .rec-wave{flex:1 1 110px;min-width:60px;height:34px} + .rec-time{min-width:0;font-size:.72rem;flex:0 0 auto} + .rec-acts{margin-left:auto} + .rec-text{font-size:.82rem} + } + /* ── Small phone ─────────────────────────────────────────────── */ + @media (max-width:380px){ + header h1{font-size:.95rem} + .tab{padding:.45rem .55rem;font-size:.72rem} + .rec-card .rec-sel{order:-1} + .rec-wave{flex:1 1 100%;order:5} + .rec-time{order:4} + } + /* ── Mobile audit pass 2 — per-tab phone fixes ───────────────── */ + @media (max-width:768px){ + #livemapFrame{height:62vh} #temp3d-frame{height:60vh} #medWrap{max-height:60vh} + } + @media (max-width:640px){ + /* Operations · Audio Control: fixed-width mute buttons + 5-item profile row */ + .mute-btn{min-width:unset;width:100%;display:block} + #audio-profile{flex:1 1 100%} #audio-profile~.btn{flex:1 1 auto} + #action-speed{width:auto;flex:0 0 auto} + /* Voice tab: nested gaps, packed subprocess row, key-input labels */ + #tab-voice .row[style*="gap:1.2rem"]{gap:.6rem} + #tab-voice .btn.btn-sm.btn-ghost{min-width:unset;flex:0 1 auto} + #tab-voice label[style*="min-width:70px"]{min-width:unset;display:block;margin-bottom:.3rem} + /* Motion & Replay: two-column min-width panes + fixed-width selects */ + #tab-motion .card > div[style*="min-width:260px"]{min-width:0} + #tab-motion #action-speed-2,#tab-motion #replay-speed{width:55px} + #tab-motion #combo-speed{width:60px} + #tab-motion #teach-duration{width:auto;flex:0 0 45px} + /* Navigation control bar → stack vertically, hide meta spans */ + #tab-navigation .card-full:has(#navMapSelect) > div:first-child{flex-direction:column;align-items:stretch} + #tab-navigation .card-full:has(#navMapSelect) > div:first-child > h3, + #tab-navigation .card-full:has(#navMapSelect) > div:first-child > select, + #tab-navigation .card-full:has(#navMapSelect) > div:first-child > .action-btn{width:100%} + #tab-navigation .card-full:has(#navMapSelect) > div:first-child > span{display:none} + /* Map Editor control bar → stack */ + #tab-mapeditor .card-full > div:first-child{flex-direction:column;align-items:stretch} + #tab-mapeditor .card-full .action-btn{flex:1 1 auto;min-width:auto} + #medMapSelect,#medBrush{width:100%} + /* Embeds shrink so they don't eat the phone viewport (but the Temperature + 3D viewer IS the tab's main content → keep it tall enough for the model + + its compact overlay panels). */ + #livemapFrame{height:50vh} #temp3d-frame{height:72vh;min-height:360px} #medWrap{max-height:40vh} + /* Recognition gallery: smaller tiles */ + .gallery-grid{grid-template-columns:repeat(auto-fill,minmax(70px,1fr));gap:.3rem} + .gallery-grid img{height:60px} + /* Temperature battery widget gaps */ + #battery-card .row{gap:.6rem} + /* Terminal: wrap header, shorter min-height */ + #tab-terminal .row{flex-wrap:wrap} #tab-terminal h3{font-size:.85rem} #tab-terminal .card{min-height:240px} + /* Recordings bulk-delete label fits */ + #rec-del-selected{white-space:nowrap;font-size:.65rem;padding:.2rem .35rem} + /* Settings: shorter log box + compact header buttons */ + #log-box{height:150px} + #tab-settings .card-full > .row button{font-size:.65rem;padding:.2rem .3rem;white-space:nowrap} + } @@ -142,6 +257,9 @@
Voice & Audio
Motion & Replay
Controller
+
Navigation
+
Live Map
+
Map Editor
Recognition
Recordings
Temperature
@@ -153,25 +271,14 @@
- -
-

Quick Voice

-
- -
- - - -
-
-
-

System Info

Loading...
+ +
Network interfaces
@@ -191,7 +298,7 @@
- +
@@ -203,7 +310,7 @@
- Persisted in data/motions/config.json · applies live via DDS + Controls the ACTIVE speaker — R1 chest (DDS) + the selected PulseAudio sink (JBL/Anker). Applies live.
@@ -295,7 +402,9 @@ + + Pause: Auto
@@ -504,6 +613,12 @@
+
+ + + + phone-free; Vision-ON auto-starts it +
Toggles take effect within ~1 second on the running Gemini session — no restart required.
@@ -533,6 +648,17 @@ +
Camera Source (R1 head — each eye points a different way)
+
+ + + +
+
+ + + straightens the wide-angle lens (approx — no factory calibration) +
Each button rebuilds the capture pipeline (~0.5 s). Modes match the @@ -628,14 +754,34 @@
+ +
+

🔋 Battery

+
+
+
+
+
--%
+
--
+
+
+
+
Voltage
--
+
Current
--
+
Pack temp
--
+
Cycles
--
+
+
+
Reading battery…
+
-
Live motor surface/winding temperatures from rt/lowstate on the full - G1 (29 DOF). Blue ≈ 30°C → red ≈ 120°C. Drag to orbit, scroll to zoom. + R1 (29 DOF). Blue ≈ 30°C → red ≈ 120°C. Drag to orbit, scroll to zoom. Streamed over /ws/motor-temps — no second DDS subscriber.
@@ -740,6 +886,143 @@
+ +
+ + + + +
+ + +
+

Places

+ +
+ + +
+

Save current pose as place

+
+ + +
+
+ Captures the robot's current map pose under this name. Drive (or teleop) the robot to the + spot first, then save. Saved places appear in the list to the left. +
+
+ + +
+

Missions

+ +
+ Multi-waypoint routes / patrols defined in web_nav3. Run executes the full sequence. +
+
+ + +
+
+

MAP NAVIGATION

+ + + map: + + Click: + + + + + + + + connecting… +
+
+ +
+
VIEW — pick GOAL to drive, or ADD to bookmark places. Cyan = robot, green dots = this map's places. Each map has its own places. Use the Places list to Go / Move / rename / delete.
+
+ +
+
+ + +
+ +
+

Live Map — full web_nav3 dashboard

+ +
+
+ Full web_nav3 dashboard (live map, set-pose, manual goals, missions) embedded from the robot. + Also available standalone at + :8765. + If it stays blank, check that bringup + rosbridge are alive (see the Navigation tab) and that the + robot is reachable on the network. +
+ +
+ + +
+
+
+

MAP EDITOR

+ + + map: + MODE — + + Tool: + + + + Brush + + + + + + + + + load a map… +
+
+ +
+
+ Edit a SAVED map. Pick a map → Load & Edit. 🧽 Erase removes black phantom obstacles (paints them free); ⬛ Wall paints virtual walls / keep-outs. Click-drag to paint (brush size above). Save stores the edits per-map and applies them to the robot's navigation — it stops avoiding erased points and treats painted walls as keep-outs. Yellow = your edits. The original map .db is never modified. +
+
+
+
@@ -754,15 +1037,25 @@

Saved Records

- - + + + + @@ -354,6 +354,38 @@ font-size: 0.9em; color: rgba(255, 255, 255, 0.6); } + + /* ── Phone layout: the Stats (top-left) and Controls (top-right) panels + are 250px each and overlap on a narrow screen. Shrink them to fit + side-by-side, move the legend to a full-width strip at the bottom. */ + @media (max-width: 600px) { + .header { top: 8px; left: 8px; right: 8px; padding: 8px 12px; border-radius: 10px; } + .header h1 { font-size: 1.05em; } + .status { font-size: .8em; gap: 6px; } + .stats-panel { + top: 56px; left: 8px; min-width: 0; width: 43vw; max-width: 178px; + padding: 9px 10px; border-radius: 10px; + } + .stats-panel h2 { font-size: .82em; margin-bottom: 7px; } + .stat-item { margin-bottom: 0; padding: 4px 0; } + .stat-label { font-size: .72em; } + .stat-value { font-size: .82em; } + .controls-panel { + top: 56px; right: 8px; left: auto; width: 43vw; max-width: 178px; + padding: 8px; border-radius: 10px; + } + .controls-panel h2 { font-size: .82em; margin-bottom: 6px; } + .control-btn { font-size: .72em; padding: 6px 4px; margin-bottom: 4px; } + .temp-legend { + bottom: 8px; left: 8px; right: 8px; padding: 9px 12px; border-radius: 10px; + } + .temp-legend h3 { font-size: .85em; margin-bottom: 7px; } + .gradient-bar { width: 100%; } + .gradient-labels { font-size: .72em; } + .motor-info-panel { + bottom: 8px; left: 8px; right: 8px; min-width: 0; width: auto; max-height: 42vh; + } + } @@ -361,7 +393,7 @@
-
Loading G1 Robot Model
+
Loading R1 Robot Model
0%
@@ -370,7 +402,7 @@
-

🤖 G1 3D Temperature Monitor

+

🤖 R1 3D Temperature Monitor

Connecting... @@ -651,7 +683,7 @@ document.getElementById('loadingProgress').textContent = 'Fetching URDF...'; // Fetch URDF - const urdfText = await fetch('/static/temp3d/g1/g1_29dof_rev_1_0.urdf').then(r => r.text()); + const urdfText = await fetch('/static/temp3d/r1/R1.urdf').then(r => r.text()); const { links, joints } = parseURDF(urdfText); document.getElementById('loadingProgress').textContent = 'Loading meshes...'; @@ -668,7 +700,7 @@ const link = links[linkName]; if (!link.visual) continue; - const meshPath = link.visual.replace('meshes/', '/static/temp3d/g1/meshes/'); + const meshPath = link.visual.replace('meshes/', '/static/temp3d/r1/meshes/'); try { const geometry = await new Promise((resolve, reject) => { @@ -711,8 +743,10 @@ linkObjects[linkName] = linkObj; } - // Find root link (pelvis) - const rootLinkName = 'pelvis'; + // Find the root link dynamically: the one link that is never a + // joint's child. (G1 = 'pelvis', R1 = 'pelvis_link' — don't hardcode.) + const childLinkNames = new Set(Object.values(joints).map(j => j.child)); + const rootLinkName = Object.keys(links).find(n => !childLinkNames.has(n)) || 'pelvis'; if (linkObjects[rootLinkName]) { robotRoot.add(linkObjects[rootLinkName]); } diff --git a/dashboard/static/temp3d/r1/R1.urdf b/dashboard/static/temp3d/r1/R1.urdf new file mode 100644 index 0000000..ffef601 --- /dev/null +++ b/dashboard/static/temp3d/r1/R1.urdf @@ -0,0 +1,1132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dashboard/static/temp3d/r1/meshes/head_pitch_link.STL b/dashboard/static/temp3d/r1/meshes/head_pitch_link.STL new file mode 100644 index 0000000..3c371b0 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/head_pitch_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/head_yaw_link.STL b/dashboard/static/temp3d/r1/meshes/head_yaw_link.STL new file mode 100644 index 0000000..e4e2812 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/head_yaw_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/imu_in_pelvis_link.STL b/dashboard/static/temp3d/r1/meshes/imu_in_pelvis_link.STL new file mode 100644 index 0000000..d94f406 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/imu_in_pelvis_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/left_ankle_A_link.STL b/dashboard/static/temp3d/r1/meshes/left_ankle_A_link.STL new file mode 100644 index 0000000..3e89e9a Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/left_ankle_A_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/left_ankle_A_rod_link.STL b/dashboard/static/temp3d/r1/meshes/left_ankle_A_rod_link.STL new file mode 100644 index 0000000..b9b7ec7 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/left_ankle_A_rod_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/left_ankle_B_link.STL b/dashboard/static/temp3d/r1/meshes/left_ankle_B_link.STL new file mode 100644 index 0000000..67f9616 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/left_ankle_B_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/left_ankle_B_rod_link.STL b/dashboard/static/temp3d/r1/meshes/left_ankle_B_rod_link.STL new file mode 100644 index 0000000..b0a19d7 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/left_ankle_B_rod_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/left_ankle_constraint_A_link.STL b/dashboard/static/temp3d/r1/meshes/left_ankle_constraint_A_link.STL new file mode 100644 index 0000000..199623e Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/left_ankle_constraint_A_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/left_ankle_constraint_B_link.STL b/dashboard/static/temp3d/r1/meshes/left_ankle_constraint_B_link.STL new file mode 100644 index 0000000..8506bab Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/left_ankle_constraint_B_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/left_ankle_pitch_link.STL b/dashboard/static/temp3d/r1/meshes/left_ankle_pitch_link.STL new file mode 100644 index 0000000..a62f7e7 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/left_ankle_pitch_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/left_ankle_roll_link.STL b/dashboard/static/temp3d/r1/meshes/left_ankle_roll_link.STL new file mode 100644 index 0000000..56fd2e5 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/left_ankle_roll_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/left_elbow_link.STL b/dashboard/static/temp3d/r1/meshes/left_elbow_link.STL new file mode 100644 index 0000000..b8a8a3a Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/left_elbow_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/left_hip_pitch_link.STL b/dashboard/static/temp3d/r1/meshes/left_hip_pitch_link.STL new file mode 100644 index 0000000..94ffb8d Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/left_hip_pitch_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/left_hip_roll_link.STL b/dashboard/static/temp3d/r1/meshes/left_hip_roll_link.STL new file mode 100644 index 0000000..0edf56a Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/left_hip_roll_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/left_hip_yaw_link.STL b/dashboard/static/temp3d/r1/meshes/left_hip_yaw_link.STL new file mode 100644 index 0000000..dc6fdf1 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/left_hip_yaw_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/left_knee_collision.STL b/dashboard/static/temp3d/r1/meshes/left_knee_collision.STL new file mode 100644 index 0000000..31a47b7 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/left_knee_collision.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/left_knee_link.STL b/dashboard/static/temp3d/r1/meshes/left_knee_link.STL new file mode 100644 index 0000000..affdf6a Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/left_knee_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/left_shoulder_pitch_link.STL b/dashboard/static/temp3d/r1/meshes/left_shoulder_pitch_link.STL new file mode 100644 index 0000000..c2e12ed Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/left_shoulder_pitch_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/left_shoulder_roll_link.STL b/dashboard/static/temp3d/r1/meshes/left_shoulder_roll_link.STL new file mode 100644 index 0000000..4761ec4 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/left_shoulder_roll_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/left_shoulder_yaw_link.STL b/dashboard/static/temp3d/r1/meshes/left_shoulder_yaw_link.STL new file mode 100644 index 0000000..d29b19c Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/left_shoulder_yaw_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/left_wrist_roll_link.STL b/dashboard/static/temp3d/r1/meshes/left_wrist_roll_link.STL new file mode 100644 index 0000000..11a7775 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/left_wrist_roll_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/pelvis_link.STL b/dashboard/static/temp3d/r1/meshes/pelvis_link.STL new file mode 100644 index 0000000..ce076f3 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/pelvis_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/right_ankle_A_link.STL b/dashboard/static/temp3d/r1/meshes/right_ankle_A_link.STL new file mode 100644 index 0000000..75964a9 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/right_ankle_A_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/right_ankle_A_rod_link.STL b/dashboard/static/temp3d/r1/meshes/right_ankle_A_rod_link.STL new file mode 100644 index 0000000..291ae46 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/right_ankle_A_rod_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/right_ankle_B_link.STL b/dashboard/static/temp3d/r1/meshes/right_ankle_B_link.STL new file mode 100644 index 0000000..8d52e5b Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/right_ankle_B_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/right_ankle_B_rod_link.STL b/dashboard/static/temp3d/r1/meshes/right_ankle_B_rod_link.STL new file mode 100644 index 0000000..f0702c2 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/right_ankle_B_rod_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/right_ankle_constraint_A_link.STL b/dashboard/static/temp3d/r1/meshes/right_ankle_constraint_A_link.STL new file mode 100644 index 0000000..e482884 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/right_ankle_constraint_A_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/right_ankle_constraint_B_link.STL b/dashboard/static/temp3d/r1/meshes/right_ankle_constraint_B_link.STL new file mode 100644 index 0000000..93d8904 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/right_ankle_constraint_B_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/right_ankle_pitch_link.STL b/dashboard/static/temp3d/r1/meshes/right_ankle_pitch_link.STL new file mode 100644 index 0000000..38bda70 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/right_ankle_pitch_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/right_ankle_roll_link.STL b/dashboard/static/temp3d/r1/meshes/right_ankle_roll_link.STL new file mode 100644 index 0000000..625316e Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/right_ankle_roll_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/right_elbow_link.STL b/dashboard/static/temp3d/r1/meshes/right_elbow_link.STL new file mode 100644 index 0000000..609da59 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/right_elbow_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/right_hip_pitch_link.STL b/dashboard/static/temp3d/r1/meshes/right_hip_pitch_link.STL new file mode 100644 index 0000000..02c0891 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/right_hip_pitch_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/right_hip_roll_link.STL b/dashboard/static/temp3d/r1/meshes/right_hip_roll_link.STL new file mode 100644 index 0000000..b34060d Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/right_hip_roll_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/right_hip_yaw_link.STL b/dashboard/static/temp3d/r1/meshes/right_hip_yaw_link.STL new file mode 100644 index 0000000..aa02b54 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/right_hip_yaw_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/right_knee_collision.STL b/dashboard/static/temp3d/r1/meshes/right_knee_collision.STL new file mode 100644 index 0000000..adf3d2f Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/right_knee_collision.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/right_knee_link.STL b/dashboard/static/temp3d/r1/meshes/right_knee_link.STL new file mode 100644 index 0000000..d721bc0 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/right_knee_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/right_shoulder_pitch_link.STL b/dashboard/static/temp3d/r1/meshes/right_shoulder_pitch_link.STL new file mode 100644 index 0000000..d32039b Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/right_shoulder_pitch_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/right_shoulder_roll_link.STL b/dashboard/static/temp3d/r1/meshes/right_shoulder_roll_link.STL new file mode 100644 index 0000000..06d36de Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/right_shoulder_roll_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/right_shoulder_yaw_link.STL b/dashboard/static/temp3d/r1/meshes/right_shoulder_yaw_link.STL new file mode 100644 index 0000000..ee47b61 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/right_shoulder_yaw_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/right_wrist_roll_link.STL b/dashboard/static/temp3d/r1/meshes/right_wrist_roll_link.STL new file mode 100644 index 0000000..7f6c67c Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/right_wrist_roll_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/torso_collision.stl b/dashboard/static/temp3d/r1/meshes/torso_collision.stl new file mode 100644 index 0000000..a4615ba Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/torso_collision.stl differ diff --git a/dashboard/static/temp3d/r1/meshes/waist_roll_link.STL b/dashboard/static/temp3d/r1/meshes/waist_roll_link.STL new file mode 100644 index 0000000..0b6da87 Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/waist_roll_link.STL differ diff --git a/dashboard/static/temp3d/r1/meshes/waist_yaw_link.STL b/dashboard/static/temp3d/r1/meshes/waist_yaw_link.STL new file mode 100644 index 0000000..304f8ac Binary files /dev/null and b/dashboard/static/temp3d/r1/meshes/waist_yaw_link.STL differ diff --git a/dashboard/temp_motor_map.py b/dashboard/temp_motor_map.py index 281951e..6f04875 100644 --- a/dashboard/temp_motor_map.py +++ b/dashboard/temp_motor_map.py @@ -1,41 +1,54 @@ -"""G1 29-DoF motor → name / mesh mapping for the 3D temperature dashboard. +"""R1 26-DoF motor → name / mesh mapping for the 3D temperature dashboard. -Ported verbatim from Marcus/Features/TempMonitor/config_g1.py so the copied -three.js front-end (static/temp3d/index.html) binds temperature colours to the -correct STL meshes. `build_payload()` turns the arm controller's raw lowstate -snapshot into the exact 'motor_update' payload shape that front-end expects. +Keys are the rt/lowstate motor_state SLOT index (== the "motor_id" the arm +controller emits). The R1's 26 joints do NOT occupy contiguous slots — the +authoritative layout is unitree_sdk2 example/r1 `joint_idx_in_idl` + +`R1JointIndex`: legs 0-11, waist_roll=12/waist_yaw=13, left arm 15-19, right arm +22-26, head_pitch=29/head_yaw=30 (slots 14,20,21,27,28 unused). Link names match +static/temp3d/r1/R1.urdf. `build_payload()` turns the arm controller's lowstate +snapshot into the 'motor_update' payload the three.js front-end expects. """ from __future__ import annotations from typing import Any, Optional -# Motor ID → human name (29 motors = 29 DOF) +# The 26 rt/lowstate slots the R1 populates, in body order — the arm controller +# samples exactly these (so Total Motors == 26 and every entry maps to a mesh). +R1_MOTOR_SLOTS: list[int] = [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, # legs (L then R) + 12, 13, # waist roll, yaw + 15, 16, 17, 18, 19, # left arm: shoulder p/r/y, elbow, wrist_roll + 22, 23, 24, 25, 26, # right arm + 29, 30, # head pitch, yaw +] + +# rt/lowstate slot → human name MOTOR_NAMES: dict[int, str] = { 0: "Left Hip Pitch", 1: "Left Hip Roll", 2: "Left Hip Yaw", 3: "Left Knee", 4: "Left Ankle Pitch", 5: "Left Ankle Roll", 6: "Right Hip Pitch", 7: "Right Hip Roll", 8: "Right Hip Yaw", 9: "Right Knee", 10: "Right Ankle Pitch", 11: "Right Ankle Roll", - 12: "Waist Yaw", 13: "Waist Roll", 14: "Waist Pitch", + 12: "Waist Roll", 13: "Waist Yaw", 15: "Left Shoulder Pitch", 16: "Left Shoulder Roll", 17: "Left Shoulder Yaw", - 18: "Left Elbow", 19: "Left Wrist Roll", 20: "Left Wrist Pitch", 21: "Left Wrist Yaw", + 18: "Left Elbow", 19: "Left Wrist Roll", 22: "Right Shoulder Pitch", 23: "Right Shoulder Roll", 24: "Right Shoulder Yaw", - 25: "Right Elbow", 26: "Right Wrist Roll", 27: "Right Wrist Pitch", 28: "Right Wrist Yaw", + 25: "Right Elbow", 26: "Right Wrist Roll", + 29: "Head Pitch", 30: "Head Yaw", } -# Motor ID → URDF link / STL mesh name +# rt/lowstate slot → R1 URDF link / STL mesh name MOTOR_TO_MESH: dict[int, str] = { 0: "left_hip_pitch_link", 1: "left_hip_roll_link", 2: "left_hip_yaw_link", 3: "left_knee_link", 4: "left_ankle_pitch_link", 5: "left_ankle_roll_link", 6: "right_hip_pitch_link", 7: "right_hip_roll_link", 8: "right_hip_yaw_link", 9: "right_knee_link", 10: "right_ankle_pitch_link", 11: "right_ankle_roll_link", - 12: "waist_yaw_link", 13: "waist_roll_link", 14: "torso_link", + 12: "waist_roll_link", 13: "waist_yaw_link", 15: "left_shoulder_pitch_link", 16: "left_shoulder_roll_link", 17: "left_shoulder_yaw_link", - 18: "left_elbow_link", 19: "left_wrist_roll_link", 20: "left_wrist_pitch_link", - 21: "left_wrist_yaw_link", + 18: "left_elbow_link", 19: "left_wrist_roll_link", 22: "right_shoulder_pitch_link", 23: "right_shoulder_roll_link", 24: "right_shoulder_yaw_link", - 25: "right_elbow_link", 26: "right_wrist_roll_link", 27: "right_wrist_pitch_link", - 28: "right_wrist_yaw_link", + 25: "right_elbow_link", 26: "right_wrist_roll_link", + 29: "head_pitch_link", 30: "head_yaw_link", } # Temperature thresholds (°C) — the three.js gradient maps MIN→MAX (blue→red). diff --git a/dashboard/websockets/log_stream.py b/dashboard/websockets/log_stream.py index 17b9af3..6e3bb94 100644 --- a/dashboard/websockets/log_stream.py +++ b/dashboard/websockets/log_stream.py @@ -19,27 +19,45 @@ MAX_WATCHERS = 50 # Ring buffer of recent log lines (shared across connections). _recent: deque[str] = deque(maxlen=500) -_watchers: set[asyncio.Queue] = set() +# Each watcher is an (event_loop, queue) pair. We keep the loop so cross-thread +# producers can schedule the enqueue on the consumer's loop (asyncio.Queue is +# NOT thread-safe — calling put_nowait off-loop neither wakes the parked +# `await queue.get()` nor safely mutates the queue's internals). +_watchers: set[tuple[asyncio.AbstractEventLoop, asyncio.Queue]] = set() _watchers_lock = threading.Lock() def push_log_line(line: str): """Called from the logging system to feed new lines. - May be called from any thread (logging is multi-threaded), so we - snapshot the watchers under a lock before iterating. + May be called from ANY thread (logging is multi-threaded), so the append + to _recent and the per-watcher enqueue are done together under the same + lock that log_ws holds while snapshotting history + registering — that + closes the history/live overlap window so a connecting client can't see a + line both in its history replay and again live. The enqueue itself is + marshalled onto each watcher's loop via call_soon_threadsafe because + asyncio.Queue.put_nowait is not safe to call from a foreign thread. """ - _recent.append(line) with _watchers_lock: + _recent.append(line) snapshot = list(_watchers) - for q in snapshot: + for loop, q in snapshot: try: - q.put_nowait(line) - except asyncio.QueueFull: - # Drop on overflow rather than block — logs are not critical data + loop.call_soon_threadsafe(_safe_put, q, line) + except RuntimeError: + # Loop already closed — watcher is going away; skip it. pass +def _safe_put(q: asyncio.Queue, line: str) -> None: + """Enqueue on the consumer's own loop thread (so it's safe).""" + try: + q.put_nowait(line) + except asyncio.QueueFull: + # Drop on overflow rather than block — logs are not critical data + pass + + # Register with the logger so all log records are pushed to WS clients. # Wrap so a logger registration failure doesn't break Dashboard import. try: @@ -52,16 +70,22 @@ except Exception: async def log_ws(ws: WebSocket): await ws.accept() + loop = asyncio.get_running_loop() + queue: asyncio.Queue[str] = asyncio.Queue(maxsize=200) + watcher = (loop, queue) with _watchers_lock: if len(_watchers) >= MAX_WATCHERS: await ws.close(code=1013, reason="Too many log watchers") return - queue: asyncio.Queue[str] = asyncio.Queue(maxsize=200) - _watchers.add(queue) + # Register the live queue and snapshot history under the SAME lock that + # push_log_line holds — so every line is either in this history + # snapshot or arrives on the queue, never both (no replay duplicates). + _watchers.add(watcher) + history = list(_recent) try: # Send recent history - for line in list(_recent): + for line in history: await ws.send_text(line) while True: @@ -77,4 +101,4 @@ async def log_ws(ws: WebSocket): pass finally: with _watchers_lock: - _watchers.discard(queue) + _watchers.discard(watcher) diff --git a/data/audio/Etisalat.wav b/data/audio/Etisalat.wav deleted file mode 100644 index 4fd522e..0000000 Binary files a/data/audio/Etisalat.wav and /dev/null differ diff --git a/data/audio/Etisalat_1.wav b/data/audio/Etisalat_1.wav deleted file mode 100644 index 4fd522e..0000000 Binary files a/data/audio/Etisalat_1.wav and /dev/null differ diff --git a/data/audio/Etisalat_1_raw.wav b/data/audio/Etisalat_1_raw.wav deleted file mode 100644 index 4fd522e..0000000 Binary files a/data/audio/Etisalat_1_raw.wav and /dev/null differ diff --git a/data/audio/Etisalat_raw.wav b/data/audio/Etisalat_raw.wav deleted file mode 100644 index 4fd522e..0000000 Binary files a/data/audio/Etisalat_raw.wav and /dev/null differ diff --git a/data/audio/Gccc.wav b/data/audio/Gccc.wav deleted file mode 100644 index 4eea79d..0000000 Binary files a/data/audio/Gccc.wav and /dev/null differ diff --git a/data/audio/Gccc_1.wav b/data/audio/Gccc_1.wav deleted file mode 100644 index 4eea79d..0000000 Binary files a/data/audio/Gccc_1.wav and /dev/null differ diff --git a/data/audio/Gccc_1_raw.wav b/data/audio/Gccc_1_raw.wav deleted file mode 100644 index 4eea79d..0000000 Binary files a/data/audio/Gccc_1_raw.wav and /dev/null differ diff --git a/data/audio/Gccc_raw.wav b/data/audio/Gccc_raw.wav deleted file mode 100644 index 4eea79d..0000000 Binary files a/data/audio/Gccc_raw.wav and /dev/null differ diff --git a/data/audio/Gccmm.wav b/data/audio/Gccmm.wav deleted file mode 100644 index 56bf5ca..0000000 Binary files a/data/audio/Gccmm.wav and /dev/null differ diff --git a/data/audio/Gccmm_1.wav b/data/audio/Gccmm_1.wav deleted file mode 100644 index 56bf5ca..0000000 Binary files a/data/audio/Gccmm_1.wav and /dev/null differ diff --git a/data/audio/Gccmm_1_raw.wav b/data/audio/Gccmm_1_raw.wav deleted file mode 100644 index 56bf5ca..0000000 Binary files a/data/audio/Gccmm_1_raw.wav and /dev/null differ diff --git a/data/audio/Gccmm_raw.wav b/data/audio/Gccmm_raw.wav deleted file mode 100644 index 56bf5ca..0000000 Binary files a/data/audio/Gccmm_raw.wav and /dev/null differ diff --git a/data/audio/Ghy.wav b/data/audio/Ghy.wav deleted file mode 100644 index 95d7e38..0000000 Binary files a/data/audio/Ghy.wav and /dev/null differ diff --git a/data/audio/Ghy_1.wav b/data/audio/Ghy_1.wav deleted file mode 100644 index 95d7e38..0000000 Binary files a/data/audio/Ghy_1.wav and /dev/null differ diff --git a/data/audio/Ghy_1_raw.wav b/data/audio/Ghy_1_raw.wav deleted file mode 100644 index 95d7e38..0000000 Binary files a/data/audio/Ghy_1_raw.wav and /dev/null differ diff --git a/data/audio/Ghy_raw.wav b/data/audio/Ghy_raw.wav deleted file mode 100644 index 95d7e38..0000000 Binary files a/data/audio/Ghy_raw.wav and /dev/null differ diff --git a/data/audio/Ghyd.wav b/data/audio/Ghyd.wav deleted file mode 100644 index ce6ffac..0000000 Binary files a/data/audio/Ghyd.wav and /dev/null differ diff --git a/data/audio/Ghyd_1.wav b/data/audio/Ghyd_1.wav deleted file mode 100644 index f463168..0000000 Binary files a/data/audio/Ghyd_1.wav and /dev/null differ diff --git a/data/audio/Ghyd_1_raw.wav b/data/audio/Ghyd_1_raw.wav deleted file mode 100644 index f463168..0000000 Binary files a/data/audio/Ghyd_1_raw.wav and /dev/null differ diff --git a/data/audio/Ghyd_2.wav b/data/audio/Ghyd_2.wav deleted file mode 100644 index f463168..0000000 Binary files a/data/audio/Ghyd_2.wav and /dev/null differ diff --git a/data/audio/Ghyd_2_raw.wav b/data/audio/Ghyd_2_raw.wav deleted file mode 100644 index f463168..0000000 Binary files a/data/audio/Ghyd_2_raw.wav and /dev/null differ diff --git a/data/audio/Ghyd_raw.wav b/data/audio/Ghyd_raw.wav deleted file mode 100644 index ce6ffac..0000000 Binary files a/data/audio/Ghyd_raw.wav and /dev/null differ diff --git a/data/audio/Ghyddf.wav b/data/audio/Ghyddf.wav deleted file mode 100644 index 980bab3..0000000 Binary files a/data/audio/Ghyddf.wav and /dev/null differ diff --git a/data/audio/Ghyddf_1.wav b/data/audio/Ghyddf_1.wav deleted file mode 100644 index 980bab3..0000000 Binary files a/data/audio/Ghyddf_1.wav and /dev/null differ diff --git a/data/audio/Ghyddf_1_raw.wav b/data/audio/Ghyddf_1_raw.wav deleted file mode 100644 index 980bab3..0000000 Binary files a/data/audio/Ghyddf_1_raw.wav and /dev/null differ diff --git a/data/audio/Ghyddf_raw.wav b/data/audio/Ghyddf_raw.wav deleted file mode 100644 index 980bab3..0000000 Binary files a/data/audio/Ghyddf_raw.wav and /dev/null differ diff --git a/data/audio/Test_20260515_100606.wav b/data/audio/Test_20260515_100606.wav deleted file mode 100644 index 99f6f11..0000000 Binary files a/data/audio/Test_20260515_100606.wav and /dev/null differ diff --git a/data/audio/Test_20260515_100606_raw.wav b/data/audio/Test_20260515_100606_raw.wav deleted file mode 100644 index 99f6f11..0000000 Binary files a/data/audio/Test_20260515_100606_raw.wav and /dev/null differ diff --git a/data/audio/e26.wav b/data/audio/e26.wav deleted file mode 100644 index 72c3355..0000000 Binary files a/data/audio/e26.wav and /dev/null differ diff --git a/data/audio/e265.wav b/data/audio/e265.wav deleted file mode 100644 index 8008a83..0000000 Binary files a/data/audio/e265.wav and /dev/null differ diff --git a/data/audio/e265_raw.wav b/data/audio/e265_raw.wav deleted file mode 100644 index 8008a83..0000000 Binary files a/data/audio/e265_raw.wav and /dev/null differ diff --git a/data/audio/e267.wav b/data/audio/e267.wav deleted file mode 100644 index 8f02f05..0000000 Binary files a/data/audio/e267.wav and /dev/null differ diff --git a/data/audio/e267_1.wav b/data/audio/e267_1.wav deleted file mode 100644 index 8f02f05..0000000 Binary files a/data/audio/e267_1.wav and /dev/null differ diff --git a/data/audio/e267_1_raw.wav b/data/audio/e267_1_raw.wav deleted file mode 100644 index 8f02f05..0000000 Binary files a/data/audio/e267_1_raw.wav and /dev/null differ diff --git a/data/audio/e267_raw.wav b/data/audio/e267_raw.wav deleted file mode 100644 index 8f02f05..0000000 Binary files a/data/audio/e267_raw.wav and /dev/null differ diff --git a/data/audio/e268.wav b/data/audio/e268.wav deleted file mode 100644 index a299ebf..0000000 Binary files a/data/audio/e268.wav and /dev/null differ diff --git a/data/audio/e268_1.wav b/data/audio/e268_1.wav deleted file mode 100644 index 5f28b08..0000000 Binary files a/data/audio/e268_1.wav and /dev/null differ diff --git a/data/audio/e268_1_raw.wav b/data/audio/e268_1_raw.wav deleted file mode 100644 index 5f28b08..0000000 Binary files a/data/audio/e268_1_raw.wav and /dev/null differ diff --git a/data/audio/e268_2.wav b/data/audio/e268_2.wav deleted file mode 100644 index 5f28b08..0000000 Binary files a/data/audio/e268_2.wav and /dev/null differ diff --git a/data/audio/e268_2_raw.wav b/data/audio/e268_2_raw.wav deleted file mode 100644 index 5f28b08..0000000 Binary files a/data/audio/e268_2_raw.wav and /dev/null differ diff --git a/data/audio/e268_raw.wav b/data/audio/e268_raw.wav deleted file mode 100644 index a299ebf..0000000 Binary files a/data/audio/e268_raw.wav and /dev/null differ diff --git a/data/audio/e26_1.wav b/data/audio/e26_1.wav deleted file mode 100644 index 6ce9124..0000000 Binary files a/data/audio/e26_1.wav and /dev/null differ diff --git a/data/audio/e26_1_raw.wav b/data/audio/e26_1_raw.wav deleted file mode 100644 index 6ce9124..0000000 Binary files a/data/audio/e26_1_raw.wav and /dev/null differ diff --git a/data/audio/e26_2.wav b/data/audio/e26_2.wav deleted file mode 100644 index 6ce9124..0000000 Binary files a/data/audio/e26_2.wav and /dev/null differ diff --git a/data/audio/e26_2_raw.wav b/data/audio/e26_2_raw.wav deleted file mode 100644 index 6ce9124..0000000 Binary files a/data/audio/e26_2_raw.wav and /dev/null differ diff --git a/data/audio/e26_3.wav b/data/audio/e26_3.wav deleted file mode 100644 index 8a7233a..0000000 Binary files a/data/audio/e26_3.wav and /dev/null differ diff --git a/data/audio/e26_3_raw.wav b/data/audio/e26_3_raw.wav deleted file mode 100644 index 8a7233a..0000000 Binary files a/data/audio/e26_3_raw.wav and /dev/null differ diff --git a/data/audio/e26_4.wav b/data/audio/e26_4.wav deleted file mode 100644 index 8008a83..0000000 Binary files a/data/audio/e26_4.wav and /dev/null differ diff --git a/data/audio/e26_4_raw.wav b/data/audio/e26_4_raw.wav deleted file mode 100644 index 8008a83..0000000 Binary files a/data/audio/e26_4_raw.wav and /dev/null differ diff --git a/data/audio/e26_raw.wav b/data/audio/e26_raw.wav deleted file mode 100644 index 72c3355..0000000 Binary files a/data/audio/e26_raw.wav and /dev/null differ diff --git a/data/audio/gh.wav b/data/audio/gh.wav deleted file mode 100644 index 2d6990d..0000000 Binary files a/data/audio/gh.wav and /dev/null differ diff --git a/data/audio/gh_1.wav b/data/audio/gh_1.wav deleted file mode 100644 index 2d6990d..0000000 Binary files a/data/audio/gh_1.wav and /dev/null differ diff --git a/data/audio/gh_1_raw.wav b/data/audio/gh_1_raw.wav deleted file mode 100644 index 2d6990d..0000000 Binary files a/data/audio/gh_1_raw.wav and /dev/null differ diff --git a/data/audio/gh_raw.wav b/data/audio/gh_raw.wav deleted file mode 100644 index 2d6990d..0000000 Binary files a/data/audio/gh_raw.wav and /dev/null differ diff --git a/data/audio/ghghtttt.wav b/data/audio/ghghtttt.wav deleted file mode 100644 index 4e50d0e..0000000 Binary files a/data/audio/ghghtttt.wav and /dev/null differ diff --git a/data/audio/ghghtttt_1.wav b/data/audio/ghghtttt_1.wav deleted file mode 100644 index 4e50d0e..0000000 Binary files a/data/audio/ghghtttt_1.wav and /dev/null differ diff --git a/data/audio/ghghtttt_1_raw.wav b/data/audio/ghghtttt_1_raw.wav deleted file mode 100644 index 4e50d0e..0000000 Binary files a/data/audio/ghghtttt_1_raw.wav and /dev/null differ diff --git a/data/audio/ghghtttt_raw.wav b/data/audio/ghghtttt_raw.wav deleted file mode 100644 index 4e50d0e..0000000 Binary files a/data/audio/ghghtttt_raw.wav and /dev/null differ diff --git a/data/audio/h1l.wav b/data/audio/h1l.wav deleted file mode 100644 index 05d7c3b..0000000 Binary files a/data/audio/h1l.wav and /dev/null differ diff --git a/data/audio/h1l_1.wav b/data/audio/h1l_1.wav deleted file mode 100644 index 05d7c3b..0000000 Binary files a/data/audio/h1l_1.wav and /dev/null differ diff --git a/data/audio/h1l_1_raw.wav b/data/audio/h1l_1_raw.wav deleted file mode 100644 index 05d7c3b..0000000 Binary files a/data/audio/h1l_1_raw.wav and /dev/null differ diff --git a/data/audio/h1l_raw.wav b/data/audio/h1l_raw.wav deleted file mode 100644 index 05d7c3b..0000000 Binary files a/data/audio/h1l_raw.wav and /dev/null differ diff --git a/data/audio/hh.wav b/data/audio/hh.wav deleted file mode 100644 index adce27a..0000000 Binary files a/data/audio/hh.wav and /dev/null differ diff --git a/data/audio/hh_1.wav b/data/audio/hh_1.wav deleted file mode 100644 index adce27a..0000000 Binary files a/data/audio/hh_1.wav and /dev/null differ diff --git a/data/audio/hh_1_raw.wav b/data/audio/hh_1_raw.wav deleted file mode 100644 index adce27a..0000000 Binary files a/data/audio/hh_1_raw.wav and /dev/null differ diff --git a/data/audio/hh_raw.wav b/data/audio/hh_raw.wav deleted file mode 100644 index adce27a..0000000 Binary files a/data/audio/hh_raw.wav and /dev/null differ diff --git a/data/audio/hhhh222.wav b/data/audio/hhhh222.wav deleted file mode 100644 index fbd31f9..0000000 Binary files a/data/audio/hhhh222.wav and /dev/null differ diff --git a/data/audio/hhhh222_raw.wav b/data/audio/hhhh222_raw.wav deleted file mode 100644 index fbd31f9..0000000 Binary files a/data/audio/hhhh222_raw.wav and /dev/null differ diff --git a/data/audio/najjar.wav b/data/audio/najjar.wav deleted file mode 100644 index 217345a..0000000 Binary files a/data/audio/najjar.wav and /dev/null differ diff --git a/data/audio/najjar_1.wav b/data/audio/najjar_1.wav deleted file mode 100644 index 217345a..0000000 Binary files a/data/audio/najjar_1.wav and /dev/null differ diff --git a/data/audio/najjar_1_raw.wav b/data/audio/najjar_1_raw.wav deleted file mode 100644 index 217345a..0000000 Binary files a/data/audio/najjar_1_raw.wav and /dev/null differ diff --git a/data/audio/najjar_raw.wav b/data/audio/najjar_raw.wav deleted file mode 100644 index 217345a..0000000 Binary files a/data/audio/najjar_raw.wav and /dev/null differ diff --git a/data/audio/records.json b/data/audio/records.json deleted file mode 100644 index 01848ad..0000000 --- a/data/audio/records.json +++ /dev/null @@ -1,1302 +0,0 @@ -{ - "total_records": 36, - "last_updated": "2026-04-19 20:55:59", - "records": [ - { - "record_name": "t1", - "text": "Hello, and welcome to e& at Yas Mall.\n\nI am your interactive AI assistant, here to welcome guests, guide customers through the store, and support the e& team by creating a smarter, more personalized customer experience.\n", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-15 10:25:07", - "last_playback_finished_at": "2026-05-15 10:25:22", - "saved_at": "2026-05-15 10:25:22" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "t1.wav", - "path": "t1.wav", - "size_bytes": 695040, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 14.48 - }, - "gemini_raw_output": { - "name": "t1_raw.wav", - "path": "t1_raw.wav", - "size_bytes": 695040, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 14.48 - } - } - }, - { - "record_name": "t1_1", - "text": "Hello, and welcome to e& at Yas Mall.\n\nI am your interactive AI assistant, here to welcome guests, guide customers through the store, and support the e& team by creating a smarter, more personalized customer experience.\n", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-15 10:25:07", - "last_playback_finished_at": "2026-05-15 10:25:22", - "saved_at": "2026-05-15 10:25:26" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "t1_1.wav", - "path": "t1_1.wav", - "size_bytes": 695040, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 14.48 - }, - "gemini_raw_output": { - "name": "t1_1_raw.wav", - "path": "t1_1_raw.wav", - "size_bytes": 695040, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 14.48 - } - } - }, - { - "record_name": "t2", - "text": "I can see Hazem, General Manager at YS Lootah Tech.\n\nمرحباً أستاذ حازم، أهلاً وسهلاً بك في e& في ياس مول. سعيد بوجودك معنا اليوم.", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-15 10:26:12", - "last_playback_finished_at": "2026-05-15 10:26:22", - "saved_at": "2026-05-15 10:26:22" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "t2.wav", - "path": "t2.wav", - "size_bytes": 485760, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 10.12 - }, - "gemini_raw_output": { - "name": "t2_raw.wav", - "path": "t2_raw.wav", - "size_bytes": 485760, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 10.12 - } - } - }, - { - "record_name": "t2_1", - "text": "I can see Hazem, General Manager at YS Lootah Tech.\n\nمرحباً أستاذ حازم، أهلاً وسهلاً بك في e& في ياس مول. سعيد بوجودك معنا اليوم.", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-15 10:26:12", - "last_playback_finished_at": "2026-05-15 10:26:22", - "saved_at": "2026-05-15 10:26:25" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "t2_1.wav", - "path": "t2_1.wav", - "size_bytes": 485760, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 10.12 - }, - "gemini_raw_output": { - "name": "t2_1_raw.wav", - "path": "t2_1_raw.wav", - "size_bytes": 485760, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 10.12 - } - } - }, - { - "record_name": "t3", - "text": "I can see Liam, Sales Director from Wisemate. Hello Liam, it is a pleasure to welcome you today.", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-15 10:26:41", - "last_playback_finished_at": "2026-05-15 10:26:47", - "saved_at": "2026-05-15 10:26:47" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "t3.wav", - "path": "t3.wav", - "size_bytes": 274560, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 5.72 - }, - "gemini_raw_output": { - "name": "t3_raw.wav", - "path": "t3_raw.wav", - "size_bytes": 274560, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 5.72 - } - } - }, - { - "record_name": "t4", - "text": "I can see TT, Sales Director from Wisemate. Hello TT, welcome to e& at Yas Mall, it is great to have you here.", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-15 10:26:53", - "last_playback_finished_at": "2026-05-15 10:27:04", - "saved_at": "2026-05-15 10:27:04" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "t4.wav", - "path": "t4.wav", - "size_bytes": 276480, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 5.76 - }, - "gemini_raw_output": { - "name": "t4_raw.wav", - "path": "t4_raw.wav", - "size_bytes": 276480, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 5.76 - } - } - }, - { - "record_name": "t4_1", - "text": "I can see TT, Sales Director from Wisemate. Hello TT, welcome to e& at Yas Mall, it is great to have you here.", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-15 10:26:53", - "last_playback_finished_at": "2026-05-15 10:27:04", - "saved_at": "2026-05-15 10:27:06" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "t4_1.wav", - "path": "t4_1.wav", - "size_bytes": 276480, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 5.76 - }, - "gemini_raw_output": { - "name": "t4_1_raw.wav", - "path": "t4_1_raw.wav", - "size_bytes": 276480, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 5.76 - } - } - }, - { - "record_name": "t5", - "text": "TT, allow me to focus on your experience for a moment.", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-15 10:27:39", - "last_playback_finished_at": "2026-05-15 10:27:43", - "saved_at": "2026-05-15 10:27:43" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "t5.wav", - "path": "t5.wav", - "size_bytes": 190080, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 3.96 - }, - "gemini_raw_output": { - "name": "t5_raw.wav", - "path": "t5_raw.wav", - "size_bytes": 190080, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 3.96 - } - } - }, - { - "record_name": "t5_1", - "text": "TT, allow me to focus on your experience for a moment.", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-15 10:27:39", - "last_playback_finished_at": "2026-05-15 10:27:43", - "saved_at": "2026-05-15 10:27:50" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "t5_1.wav", - "path": "t5_1.wav", - "size_bytes": 190080, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 3.96 - }, - "gemini_raw_output": { - "name": "t5_1_raw.wav", - "path": "t5_1_raw.wav", - "size_bytes": 190080, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 3.96 - } - } - }, - { - "record_name": "t6_1", - "text": "I can see that your recent NEO Broadband issue at your villa has been resolved, and I hope your connection is now stable.\n\nBased on your profile and previous purchases, I noticed that you currently have a Samsung Dual Fold. Since you are one of our elite customers, I believe you may be interested in the latest Samsung Triple Fold.", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-15 10:28:10", - "last_playback_finished_at": "2026-05-15 10:28:32", - "saved_at": "2026-05-15 10:28:32" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "t6_1.wav", - "path": "t6_1.wav", - "size_bytes": 1015680, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 21.16 - }, - "gemini_raw_output": { - "name": "t6_1_raw.wav", - "path": "t6_1_raw.wav", - "size_bytes": 1015680, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 21.16 - } - } - }, - { - "record_name": "t6_2", - "text": "I can see that your recent NEO Broadband issue at your villa has been resolved, and I hope your connection is now stable.\n\nBased on your profile and previous purchases, I noticed that you currently have a Samsung Dual Fold. Since you are one of our elite customers, I believe you may be interested in the latest Samsung Triple Fold.", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-15 10:28:10", - "last_playback_finished_at": "2026-05-15 10:28:32", - "saved_at": "2026-05-15 10:28:45" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "t6_2.wav", - "path": "t6_2.wav", - "size_bytes": 1015680, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 21.16 - }, - "gemini_raw_output": { - "name": "t6_2_raw.wav", - "path": "t6_2_raw.wav", - "size_bytes": 1015680, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 21.16 - } - } - }, - { - "record_name": "t7", - "text": "Please follow me to the display area.\n\nWith e& SmartPay, you can take the device on easy monthly installments over 6, 12, 18, 24, or 36 months. I will also check your eligibility and whether your processing fees can be waived.\n", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-15 10:29:26", - "last_playback_finished_at": "2026-05-15 10:29:47", - "saved_at": "2026-05-15 10:29:47" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "t7.wav", - "path": "t7.wav", - "size_bytes": 768000, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 16.0 - }, - "gemini_raw_output": { - "name": "t7_raw.wav", - "path": "t7_raw.wav", - "size_bytes": 768000, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 16.0 - } - } - }, - { - "record_name": "t7_1", - "text": "Please follow me to the display area.\n\nWith e& SmartPay, you can take the device on easy monthly installments over 6, 12, 18, 24, or 36 months. I will also check your eligibility and whether your processing fees can be waived.\n", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-15 10:29:26", - "last_playback_finished_at": "2026-05-15 10:29:47", - "saved_at": "2026-05-15 10:29:58" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "t7_1.wav", - "path": "t7_1.wav", - "size_bytes": 768000, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 16.0 - }, - "gemini_raw_output": { - "name": "t7_1_raw.wav", - "path": "t7_1_raw.wav", - "size_bytes": 768000, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 16.0 - } - } - }, - { - "record_name": "t8", - "text": "Good news, TT. You may be eligible for a 36-month SmartPay plan with zero processing fees.\n\nAlso, through our Trade-In program, your Samsung Dual Fold can be used to get a discount on the new device.\n\nSince you already use e& services at your villa, we can also help you upgrade your home experience with e& SmartHome solutions, including smart lighting, climate control, security, and entertainment.\n\nOne of our e& advisors will now assist you with the final offer and activation.\n\nThank you for visiting e&. Human warmth meets embodied AI.”", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-15 10:30:40", - "last_playback_finished_at": "2026-05-15 10:31:21", - "saved_at": "2026-05-15 10:31:21" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "t8.wav", - "path": "t8.wav", - "size_bytes": 1733760, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 36.12 - }, - "gemini_raw_output": { - "name": "t8_raw.wav", - "path": "t8_raw.wav", - "size_bytes": 1733760, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 36.12 - } - } - }, - { - "record_name": "t8_1", - "text": "Good news, TT. You may be eligible for a 36-month SmartPay plan with zero processing fees.\n\nAlso, through our Trade-In program, your Samsung Dual Fold can be used to get a discount on the new device.\n\nSince you already use e& services at your villa, we can also help you upgrade your home experience with e& SmartHome solutions, including smart lighting, climate control, security, and entertainment.\n\nOne of our e& advisors will now assist you with the final offer and activation.\n\nThank you for visiting e&. Human warmth meets embodied AI.”", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-15 10:30:40", - "last_playback_finished_at": "2026-05-15 10:31:21", - "saved_at": "2026-05-15 10:31:25" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "t8_1.wav", - "path": "t8_1.wav", - "size_bytes": 1733760, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 36.12 - }, - "gemini_raw_output": { - "name": "t8_1_raw.wav", - "path": "t8_1_raw.wav", - "size_bytes": 1733760, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 36.12 - } - } - }, - { - "record_name": "t9", - "text": "I can see TT, Sales Director from Wisemate. Hello TT, welcome to e& at Yas Mall, it is great to have you here.", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-15 10:36:15", - "last_playback_finished_at": "2026-05-15 10:36:28", - "saved_at": "2026-05-15 10:36:28" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "t9.wav", - "path": "t9.wav", - "size_bytes": 376320, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 7.84 - }, - "gemini_raw_output": { - "name": "t9_raw.wav", - "path": "t9_raw.wav", - "size_bytes": 376320, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 7.84 - } - } - }, - { - "record_name": "t9_1", - "text": "I can see TT, Sales Director from Wisemate. Hello TT, welcome to e& at Yas Mall, it is great to have you here.", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-15 10:36:15", - "last_playback_finished_at": "2026-05-15 10:36:28", - "saved_at": "2026-05-15 10:36:30" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "t9_1.wav", - "path": "t9_1.wav", - "size_bytes": 376320, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 7.84 - }, - "gemini_raw_output": { - "name": "t9_1_raw.wav", - "path": "t9_1_raw.wav", - "size_bytes": 376320, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 7.84 - } - } - }, - { - "record_name": "h1l", - "text": "والله محتار بصراحة اخذ الورق العنب او الفلفل اصفر محشي لبنة", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-19 11:51:31", - "last_playback_finished_at": "2026-05-19 11:51:38", - "saved_at": "2026-05-19 11:51:38" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "h1l.wav", - "path": "h1l.wav", - "size_bytes": 257280, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 5.36 - }, - "gemini_raw_output": { - "name": "h1l_raw.wav", - "path": "h1l_raw.wav", - "size_bytes": 257280, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 5.36 - } - } - }, - { - "record_name": "h1l_1", - "text": "والله محتار بصراحة اخذ الورق العنب او الفلفل اصفر محشي لبنة", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-19 11:51:31", - "last_playback_finished_at": "2026-05-19 11:51:38", - "saved_at": "2026-05-19 11:51:44" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "h1l_1.wav", - "path": "h1l_1.wav", - "size_bytes": 257280, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 5.36 - }, - "gemini_raw_output": { - "name": "h1l_1_raw.wav", - "path": "h1l_1_raw.wav", - "size_bytes": 257280, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 5.36 - } - } - }, - { - "record_name": "Ghy", - "text": "شو يسوون هني", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-19 12:20:16", - "last_playback_finished_at": "2026-05-19 12:20:17", - "saved_at": "2026-05-19 12:20:17" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "Ghy.wav", - "path": "Ghy.wav", - "size_bytes": 49920, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 1.04 - }, - "gemini_raw_output": { - "name": "Ghy_raw.wav", - "path": "Ghy_raw.wav", - "size_bytes": 49920, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 1.04 - } - } - }, - { - "record_name": "Ghy_1", - "text": "شو يسوون هني", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-19 12:20:16", - "last_playback_finished_at": "2026-05-19 12:20:17", - "saved_at": "2026-05-19 12:20:19" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "Ghy_1.wav", - "path": "Ghy_1.wav", - "size_bytes": 49920, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 1.04 - }, - "gemini_raw_output": { - "name": "Ghy_1_raw.wav", - "path": "Ghy_1_raw.wav", - "size_bytes": 49920, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 1.04 - } - } - }, - { - "record_name": "Ghyd_1", - "text": "و هني الزين", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-19 12:20:58", - "last_playback_finished_at": "2026-05-19 12:21:04", - "saved_at": "2026-05-19 12:21:04" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "Ghyd_1.wav", - "path": "Ghyd_1.wav", - "size_bytes": 48000, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 1.0 - }, - "gemini_raw_output": { - "name": "Ghyd_1_raw.wav", - "path": "Ghyd_1_raw.wav", - "size_bytes": 48000, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 1.0 - } - } - }, - { - "record_name": "Ghyd_2", - "text": "و هني الزين", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-19 12:20:58", - "last_playback_finished_at": "2026-05-19 12:21:04", - "saved_at": "2026-05-19 12:21:05" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "Ghyd_2.wav", - "path": "Ghyd_2.wav", - "size_bytes": 48000, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 1.0 - }, - "gemini_raw_output": { - "name": "Ghyd_2_raw.wav", - "path": "Ghyd_2_raw.wav", - "size_bytes": 48000, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 1.0 - } - } - }, - { - "record_name": "Ghyddf", - "text": "هي الماكينة حق شو", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-19 12:21:49", - "last_playback_finished_at": "2026-05-19 12:21:50", - "saved_at": "2026-05-19 12:21:50" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "Ghyddf.wav", - "path": "Ghyddf.wav", - "size_bytes": 61440, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 1.28 - }, - "gemini_raw_output": { - "name": "Ghyddf_raw.wav", - "path": "Ghyddf_raw.wav", - "size_bytes": 61440, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 1.28 - } - } - }, - { - "record_name": "Ghyddf_1", - "text": "هي الماكينة حق شو", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-19 12:21:49", - "last_playback_finished_at": "2026-05-19 12:21:50", - "saved_at": "2026-05-19 12:21:53" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "Ghyddf_1.wav", - "path": "Ghyddf_1.wav", - "size_bytes": 61440, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 1.28 - }, - "gemini_raw_output": { - "name": "Ghyddf_1_raw.wav", - "path": "Ghyddf_1_raw.wav", - "size_bytes": 61440, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 1.28 - } - } - }, - { - "record_name": "Gccc", - "text": "Lo chef è andato in pausa pranzo e tornerà tra un’ora.", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-19 13:02:54", - "last_playback_finished_at": "2026-05-19 13:02:59", - "saved_at": "2026-05-19 13:02:59" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "Gccc.wav", - "path": "Gccc.wav", - "size_bytes": 184320, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 3.84 - }, - "gemini_raw_output": { - "name": "Gccc_raw.wav", - "path": "Gccc_raw.wav", - "size_bytes": 184320, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 3.84 - } - } - }, - { - "record_name": "Gccc_1", - "text": "Lo chef è andato in pausa pranzo e tornerà tra un’ora.", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-19 13:02:54", - "last_playback_finished_at": "2026-05-19 13:02:59", - "saved_at": "2026-05-19 13:03:02" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "Gccc_1.wav", - "path": "Gccc_1.wav", - "size_bytes": 184320, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 3.84 - }, - "gemini_raw_output": { - "name": "Gccc_1_raw.wav", - "path": "Gccc_1_raw.wav", - "size_bytes": 184320, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 3.84 - } - } - }, - { - "record_name": "Gccmm", - "text": "Perché chiedete tutti dello chef italiano? Io sono meglio di lui! Se vuoi lo chef italiano, è andato in pausa… lo trovi lì. Gli date uno stipendio di 50 mila, mentre a me basta che mi carichiate la batteria.", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-19 13:09:23", - "last_playback_finished_at": "2026-05-19 13:09:36", - "saved_at": "2026-05-19 13:09:36" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "Gccmm.wav", - "path": "Gccmm.wav", - "size_bytes": 585600, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 12.2 - }, - "gemini_raw_output": { - "name": "Gccmm_raw.wav", - "path": "Gccmm_raw.wav", - "size_bytes": 585600, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 12.2 - } - } - }, - { - "record_name": "Gccmm_1", - "text": "Perché chiedete tutti dello chef italiano? Io sono meglio di lui! Se vuoi lo chef italiano, è andato in pausa… lo trovi lì. Gli date uno stipendio di 50 mila, mentre a me basta che mi carichiate la batteria.", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-19 13:09:23", - "last_playback_finished_at": "2026-05-19 13:09:36", - "saved_at": "2026-05-19 13:09:38" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "Gccmm_1.wav", - "path": "Gccmm_1.wav", - "size_bytes": 585600, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 12.2 - }, - "gemini_raw_output": { - "name": "Gccmm_1_raw.wav", - "path": "Gccmm_1_raw.wav", - "size_bytes": 585600, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 12.2 - } - } - }, - { - "record_name": "najjar", - "text": "Happy birthday to you,\nHappy birthday to you,\nHappy birthday, dear Najjar,\nHappy birthday to you!", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-20 11:40:46", - "last_playback_finished_at": "2026-05-20 11:40:56", - "saved_at": "2026-05-20 11:40:56" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "najjar.wav", - "path": "najjar.wav", - "size_bytes": 455040, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 9.48 - }, - "gemini_raw_output": { - "name": "najjar_raw.wav", - "path": "najjar_raw.wav", - "size_bytes": 455040, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 9.48 - } - } - }, - { - "record_name": "e26_4", - "text": "كانت تجربة جميلة جدًا، خصوصًا مع تفاعل الأطفال والشباب والحضور. وطبعًا الهدف من وجودي كان توعوي وتعليمي للتعريف بعالم الروبوتات والذكاء الاصطناعي وقدرات التكنولوجيا الحديثة بطريقة قريبة من المجتمع… وبصراحة أول مرة أدخل مسجد وما حد يطلب مني واي فاي!", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-28 16:50:26", - "last_playback_finished_at": "2026-05-28 16:50:47", - "saved_at": "2026-05-28 16:50:47" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "e26_4.wav", - "path": "e26_4.wav", - "size_bytes": 963840, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 20.08 - }, - "gemini_raw_output": { - "name": "e26_4_raw.wav", - "path": "e26_4_raw.wav", - "size_bytes": 963840, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 20.08 - } - } - }, - { - "record_name": "e265", - "text": "كانت تجربة جميلة جدًا، خصوصًا مع تفاعل الأطفال والشباب والحضور. وطبعًا الهدف من وجودي كان توعوي وتعليمي للتعريف بعالم الروبوتات والذكاء الاصطناعي وقدرات التكنولوجيا الحديثة بطريقة قريبة من المجتمع… وبصراحة أول مرة أدخل مسجد وما حد يطلب مني واي فاي!", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-28 16:50:26", - "last_playback_finished_at": "2026-05-28 16:50:47", - "saved_at": "2026-05-28 16:50:49" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "e265.wav", - "path": "e265.wav", - "size_bytes": 963840, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 20.08 - }, - "gemini_raw_output": { - "name": "e265_raw.wav", - "path": "e265_raw.wav", - "size_bytes": 963840, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 20.08 - } - } - }, - { - "record_name": "e267", - "text": "كانت ردة الفعل جميلة جدًا، في ناس استغربوا، وفي ناس ضحكوا، والأطفال كانوا يبغون يتصورون وياي أكثر من العيدية! والأهم إن وايد ناس صار عندهم فضول يعرفون أكثر عن الروبوتات والتكنولوجيا.", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-28 16:52:19", - "last_playback_finished_at": "2026-05-28 16:52:38", - "saved_at": "2026-05-28 16:52:38" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "e267.wav", - "path": "e267.wav", - "size_bytes": 650880, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 13.56 - }, - "gemini_raw_output": { - "name": "e267_raw.wav", - "path": "e267_raw.wav", - "size_bytes": 650880, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 13.56 - } - } - }, - { - "record_name": "e267_1", - "text": "كانت ردة الفعل جميلة جدًا، في ناس استغربوا، وفي ناس ضحكوا، والأطفال كانوا يبغون يتصورون وياي أكثر من العيدية! والأهم إن وايد ناس صار عندهم فضول يعرفون أكثر عن الروبوتات والتكنولوجيا.", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-28 16:52:19", - "last_playback_finished_at": "2026-05-28 16:52:38", - "saved_at": "2026-05-28 16:52:42" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "e267_1.wav", - "path": "e267_1.wav", - "size_bytes": 650880, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 13.56 - }, - "gemini_raw_output": { - "name": "e267_1_raw.wav", - "path": "e267_1_raw.wav", - "size_bytes": 650880, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 13.56 - } - } - }, - { - "record_name": "e268_1", - "text": "أحب أشارك الناس أجواء العيد وأتعرف على الأطفال والشباب وأشجعهم على التعلم والابتكار… وبعد لازم أرجع أشحن البطارية عقب العيد، حتى الروبوتات تتعب", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-28 16:53:28", - "last_playback_finished_at": "2026-05-28 16:53:43", - "saved_at": "2026-05-28 16:53:44" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "e268_1.wav", - "path": "e268_1.wav", - "size_bytes": 499200, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 10.4 - }, - "gemini_raw_output": { - "name": "e268_1_raw.wav", - "path": "e268_1_raw.wav", - "size_bytes": 499200, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 10.4 - } - } - }, - { - "record_name": "e268_2", - "text": "أحب أشارك الناس أجواء العيد وأتعرف على الأطفال والشباب وأشجعهم على التعلم والابتكار… وبعد لازم أرجع أشحن البطارية عقب العيد، حتى الروبوتات تتعب", - "replay_count": 1, - "timeline": { - "audio_generated_at": "2026-05-28 16:53:28", - "last_playback_finished_at": "2026-05-28 16:53:43", - "saved_at": "2026-05-28 16:53:47" - }, - "audio_capture": { - "backend": "parec", - "sink": "alsa_output.platform-sound.analog-stereo", - "monitor_source": "alsa_output.platform-sound.analog-stereo.monitor", - "restored_microphone_source": "alsa_input.platform-sound.analog-stereo" - }, - "files": { - "speaker_recording": { - "name": "e268_2.wav", - "path": "e268_2.wav", - "size_bytes": 499200, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 10.4 - }, - "gemini_raw_output": { - "name": "e268_2_raw.wav", - "path": "e268_2_raw.wav", - "size_bytes": 499200, - "sample_rate": 24000, - "channels": 1, - "sample_width_bytes": 2, - "duration_seconds": 10.4 - } - } - } - ] -} \ No newline at end of file diff --git a/data/audio/t1.wav b/data/audio/t1.wav deleted file mode 100644 index 9a02a65..0000000 Binary files a/data/audio/t1.wav and /dev/null differ diff --git a/data/audio/t1_1.wav b/data/audio/t1_1.wav deleted file mode 100644 index 9a02a65..0000000 Binary files a/data/audio/t1_1.wav and /dev/null differ diff --git a/data/audio/t1_1_raw.wav b/data/audio/t1_1_raw.wav deleted file mode 100644 index 9a02a65..0000000 Binary files a/data/audio/t1_1_raw.wav and /dev/null differ diff --git a/data/audio/t1_raw.wav b/data/audio/t1_raw.wav deleted file mode 100644 index 9a02a65..0000000 Binary files a/data/audio/t1_raw.wav and /dev/null differ diff --git a/data/audio/t2.wav b/data/audio/t2.wav deleted file mode 100644 index 45f5ee2..0000000 Binary files a/data/audio/t2.wav and /dev/null differ diff --git a/data/audio/t2_1.wav b/data/audio/t2_1.wav deleted file mode 100644 index 45f5ee2..0000000 Binary files a/data/audio/t2_1.wav and /dev/null differ diff --git a/data/audio/t2_1_raw.wav b/data/audio/t2_1_raw.wav deleted file mode 100644 index 45f5ee2..0000000 Binary files a/data/audio/t2_1_raw.wav and /dev/null differ diff --git a/data/audio/t2_raw.wav b/data/audio/t2_raw.wav deleted file mode 100644 index 45f5ee2..0000000 Binary files a/data/audio/t2_raw.wav and /dev/null differ diff --git a/data/audio/t3.wav b/data/audio/t3.wav deleted file mode 100644 index 07b6936..0000000 Binary files a/data/audio/t3.wav and /dev/null differ diff --git a/data/audio/t3_raw.wav b/data/audio/t3_raw.wav deleted file mode 100644 index 07b6936..0000000 Binary files a/data/audio/t3_raw.wav and /dev/null differ diff --git a/data/audio/t4.wav b/data/audio/t4.wav deleted file mode 100644 index 77138a0..0000000 Binary files a/data/audio/t4.wav and /dev/null differ diff --git a/data/audio/t4_1.wav b/data/audio/t4_1.wav deleted file mode 100644 index 77138a0..0000000 Binary files a/data/audio/t4_1.wav and /dev/null differ diff --git a/data/audio/t4_1_raw.wav b/data/audio/t4_1_raw.wav deleted file mode 100644 index 77138a0..0000000 Binary files a/data/audio/t4_1_raw.wav and /dev/null differ diff --git a/data/audio/t4_raw.wav b/data/audio/t4_raw.wav deleted file mode 100644 index 77138a0..0000000 Binary files a/data/audio/t4_raw.wav and /dev/null differ diff --git a/data/audio/t5.wav b/data/audio/t5.wav deleted file mode 100644 index 458288c..0000000 Binary files a/data/audio/t5.wav and /dev/null differ diff --git a/data/audio/t5_1.wav b/data/audio/t5_1.wav deleted file mode 100644 index 458288c..0000000 Binary files a/data/audio/t5_1.wav and /dev/null differ diff --git a/data/audio/t5_1_raw.wav b/data/audio/t5_1_raw.wav deleted file mode 100644 index 458288c..0000000 Binary files a/data/audio/t5_1_raw.wav and /dev/null differ diff --git a/data/audio/t5_raw.wav b/data/audio/t5_raw.wav deleted file mode 100644 index 458288c..0000000 Binary files a/data/audio/t5_raw.wav and /dev/null differ diff --git a/data/audio/t6.wav b/data/audio/t6.wav deleted file mode 100644 index 458288c..0000000 Binary files a/data/audio/t6.wav and /dev/null differ diff --git a/data/audio/t6_1.wav b/data/audio/t6_1.wav deleted file mode 100644 index 0eed087..0000000 Binary files a/data/audio/t6_1.wav and /dev/null differ diff --git a/data/audio/t6_1_raw.wav b/data/audio/t6_1_raw.wav deleted file mode 100644 index 0eed087..0000000 Binary files a/data/audio/t6_1_raw.wav and /dev/null differ diff --git a/data/audio/t6_2.wav b/data/audio/t6_2.wav deleted file mode 100644 index 0eed087..0000000 Binary files a/data/audio/t6_2.wav and /dev/null differ diff --git a/data/audio/t6_2_raw.wav b/data/audio/t6_2_raw.wav deleted file mode 100644 index 0eed087..0000000 Binary files a/data/audio/t6_2_raw.wav and /dev/null differ diff --git a/data/audio/t6_raw.wav b/data/audio/t6_raw.wav deleted file mode 100644 index 458288c..0000000 Binary files a/data/audio/t6_raw.wav and /dev/null differ diff --git a/data/audio/t7.wav b/data/audio/t7.wav deleted file mode 100644 index 2f800bd..0000000 Binary files a/data/audio/t7.wav and /dev/null differ diff --git a/data/audio/t7_1.wav b/data/audio/t7_1.wav deleted file mode 100644 index 2f800bd..0000000 Binary files a/data/audio/t7_1.wav and /dev/null differ diff --git a/data/audio/t7_1_raw.wav b/data/audio/t7_1_raw.wav deleted file mode 100644 index 2f800bd..0000000 Binary files a/data/audio/t7_1_raw.wav and /dev/null differ diff --git a/data/audio/t7_raw.wav b/data/audio/t7_raw.wav deleted file mode 100644 index 2f800bd..0000000 Binary files a/data/audio/t7_raw.wav and /dev/null differ diff --git a/data/audio/t8.wav b/data/audio/t8.wav deleted file mode 100644 index 8b690c8..0000000 Binary files a/data/audio/t8.wav and /dev/null differ diff --git a/data/audio/t8_1.wav b/data/audio/t8_1.wav deleted file mode 100644 index 8b690c8..0000000 Binary files a/data/audio/t8_1.wav and /dev/null differ diff --git a/data/audio/t8_1_raw.wav b/data/audio/t8_1_raw.wav deleted file mode 100644 index 8b690c8..0000000 Binary files a/data/audio/t8_1_raw.wav and /dev/null differ diff --git a/data/audio/t8_raw.wav b/data/audio/t8_raw.wav deleted file mode 100644 index 8b690c8..0000000 Binary files a/data/audio/t8_raw.wav and /dev/null differ diff --git a/data/audio/t9.wav b/data/audio/t9.wav deleted file mode 100644 index d13aec6..0000000 Binary files a/data/audio/t9.wav and /dev/null differ diff --git a/data/audio/t9_1.wav b/data/audio/t9_1.wav deleted file mode 100644 index d13aec6..0000000 Binary files a/data/audio/t9_1.wav and /dev/null differ diff --git a/data/audio/t9_1_raw.wav b/data/audio/t9_1_raw.wav deleted file mode 100644 index d13aec6..0000000 Binary files a/data/audio/t9_1_raw.wav and /dev/null differ diff --git a/data/audio/t9_raw.wav b/data/audio/t9_raw.wav deleted file mode 100644 index d13aec6..0000000 Binary files a/data/audio/t9_raw.wav and /dev/null differ diff --git a/data/motions/config.json b/data/motions/config.json index 40a0170..e630309 100644 --- a/data/motions/config.json +++ b/data/motions/config.json @@ -16,6 +16,6 @@ }, "dashboard": { "host": "0.0.0.0", - "port": 8000 + "port": 8001 } } \ No newline at end of file diff --git a/data/recordings/20260514_174629_robot.wav b/data/recordings/20260514_174629_robot.wav deleted file mode 100644 index 4efad26..0000000 Binary files a/data/recordings/20260514_174629_robot.wav and /dev/null differ diff --git a/data/recordings/20260514_174700_user.wav b/data/recordings/20260514_174700_user.wav deleted file mode 100644 index ea42e69..0000000 Binary files a/data/recordings/20260514_174700_user.wav and /dev/null differ diff --git a/data/recordings/20260514_174701_robot.wav b/data/recordings/20260514_174701_robot.wav deleted file mode 100644 index 2c74797..0000000 Binary files a/data/recordings/20260514_174701_robot.wav and /dev/null differ diff --git a/data/recordings/20260514_174701_user.wav b/data/recordings/20260514_174701_user.wav deleted file mode 100644 index a7eedf1..0000000 Binary files a/data/recordings/20260514_174701_user.wav and /dev/null differ diff --git a/data/recordings/20260514_174714_robot.wav b/data/recordings/20260514_174714_robot.wav deleted file mode 100644 index ae54891..0000000 Binary files a/data/recordings/20260514_174714_robot.wav and /dev/null differ diff --git a/data/recordings/20260514_174714_user.wav b/data/recordings/20260514_174714_user.wav deleted file mode 100644 index 4928792..0000000 Binary files a/data/recordings/20260514_174714_user.wav and /dev/null differ diff --git a/data/recordings/20260514_174723_robot.wav b/data/recordings/20260514_174723_robot.wav deleted file mode 100644 index 729b59f..0000000 Binary files a/data/recordings/20260514_174723_robot.wav and /dev/null differ diff --git a/data/recordings/20260514_174723_user.wav b/data/recordings/20260514_174723_user.wav deleted file mode 100644 index 1c091d4..0000000 Binary files a/data/recordings/20260514_174723_user.wav and /dev/null differ diff --git a/data/recordings/20260514_175127_robot.wav b/data/recordings/20260514_175127_robot.wav deleted file mode 100644 index 0981fa0..0000000 Binary files a/data/recordings/20260514_175127_robot.wav and /dev/null differ diff --git a/data/recordings/20260514_175127_user.wav b/data/recordings/20260514_175127_user.wav deleted file mode 100644 index 58321c9..0000000 Binary files a/data/recordings/20260514_175127_user.wav and /dev/null differ diff --git a/data/recordings/20260514_175134_user.wav b/data/recordings/20260514_175134_user.wav deleted file mode 100644 index c101e63..0000000 Binary files a/data/recordings/20260514_175134_user.wav and /dev/null differ diff --git a/data/recordings/20260514_175139_robot.wav b/data/recordings/20260514_175139_robot.wav deleted file mode 100644 index c7c26b9..0000000 Binary files a/data/recordings/20260514_175139_robot.wav and /dev/null differ diff --git a/data/recordings/20260514_175147_robot.wav b/data/recordings/20260514_175147_robot.wav deleted file mode 100644 index 88aea4a..0000000 Binary files a/data/recordings/20260514_175147_robot.wav and /dev/null differ diff --git a/data/recordings/20260514_175147_user.wav b/data/recordings/20260514_175147_user.wav deleted file mode 100644 index 136898a..0000000 Binary files a/data/recordings/20260514_175147_user.wav and /dev/null differ diff --git a/data/recordings/20260514_175200_robot.wav b/data/recordings/20260514_175200_robot.wav deleted file mode 100644 index 3978066..0000000 Binary files a/data/recordings/20260514_175200_robot.wav and /dev/null differ diff --git a/data/recordings/20260514_175200_user.wav b/data/recordings/20260514_175200_user.wav deleted file mode 100644 index 571eb2d..0000000 Binary files a/data/recordings/20260514_175200_user.wav and /dev/null differ diff --git a/data/recordings/20260514_175213_robot.wav b/data/recordings/20260514_175213_robot.wav deleted file mode 100644 index e4cf577..0000000 Binary files a/data/recordings/20260514_175213_robot.wav and /dev/null differ diff --git a/data/recordings/20260514_175213_user.wav b/data/recordings/20260514_175213_user.wav deleted file mode 100644 index 4437131..0000000 Binary files a/data/recordings/20260514_175213_user.wav and /dev/null differ diff --git a/data/recordings/20260514_175221_robot.wav b/data/recordings/20260514_175221_robot.wav deleted file mode 100644 index d756367..0000000 Binary files a/data/recordings/20260514_175221_robot.wav and /dev/null differ diff --git a/data/recordings/20260514_175221_user.wav b/data/recordings/20260514_175221_user.wav deleted file mode 100644 index 850d7cd..0000000 Binary files a/data/recordings/20260514_175221_user.wav and /dev/null differ diff --git a/data/recordings/20260514_175231_robot.wav b/data/recordings/20260514_175231_robot.wav deleted file mode 100644 index 6b3190f..0000000 Binary files a/data/recordings/20260514_175231_robot.wav and /dev/null differ diff --git a/data/recordings/20260514_175231_user.wav b/data/recordings/20260514_175231_user.wav deleted file mode 100644 index 82cc264..0000000 Binary files a/data/recordings/20260514_175231_user.wav and /dev/null differ diff --git a/data/recordings/20260514_175244_user.wav b/data/recordings/20260514_175244_user.wav deleted file mode 100644 index e69de29..0000000 diff --git a/data/recordings/20260515_080052_robot.wav b/data/recordings/20260515_080052_robot.wav deleted file mode 100644 index 7307d53..0000000 Binary files a/data/recordings/20260515_080052_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_080056_robot.wav b/data/recordings/20260515_080056_robot.wav deleted file mode 100644 index 2e54790..0000000 Binary files a/data/recordings/20260515_080056_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_080056_user.wav b/data/recordings/20260515_080056_user.wav deleted file mode 100644 index 61671ca..0000000 Binary files a/data/recordings/20260515_080056_user.wav and /dev/null differ diff --git a/data/recordings/20260515_080201_user.wav b/data/recordings/20260515_080201_user.wav deleted file mode 100644 index 733a0ba..0000000 Binary files a/data/recordings/20260515_080201_user.wav and /dev/null differ diff --git a/data/recordings/20260515_080205_robot.wav b/data/recordings/20260515_080205_robot.wav deleted file mode 100644 index f7f147f..0000000 Binary files a/data/recordings/20260515_080205_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_080209_robot.wav b/data/recordings/20260515_080209_robot.wav deleted file mode 100644 index f3d70cd..0000000 Binary files a/data/recordings/20260515_080209_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_080209_user.wav b/data/recordings/20260515_080209_user.wav deleted file mode 100644 index ea5bd05..0000000 Binary files a/data/recordings/20260515_080209_user.wav and /dev/null differ diff --git a/data/recordings/20260515_080221_user.wav b/data/recordings/20260515_080221_user.wav deleted file mode 100644 index 301c476..0000000 Binary files a/data/recordings/20260515_080221_user.wav and /dev/null differ diff --git a/data/recordings/20260515_080234_robot.wav b/data/recordings/20260515_080234_robot.wav deleted file mode 100644 index 831c6a3..0000000 Binary files a/data/recordings/20260515_080234_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_080234_user.wav b/data/recordings/20260515_080234_user.wav deleted file mode 100644 index e141821..0000000 Binary files a/data/recordings/20260515_080234_user.wav and /dev/null differ diff --git a/data/recordings/20260515_080256_robot.wav b/data/recordings/20260515_080256_robot.wav deleted file mode 100644 index b9b3e8d..0000000 Binary files a/data/recordings/20260515_080256_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_080256_user.wav b/data/recordings/20260515_080256_user.wav deleted file mode 100644 index ae9f82c..0000000 Binary files a/data/recordings/20260515_080256_user.wav and /dev/null differ diff --git a/data/recordings/20260515_080302_user.wav b/data/recordings/20260515_080302_user.wav deleted file mode 100644 index 29615f5..0000000 Binary files a/data/recordings/20260515_080302_user.wav and /dev/null differ diff --git a/data/recordings/20260515_080305_user.wav b/data/recordings/20260515_080305_user.wav deleted file mode 100644 index ff3c5c3..0000000 Binary files a/data/recordings/20260515_080305_user.wav and /dev/null differ diff --git a/data/recordings/20260515_080309_robot.wav b/data/recordings/20260515_080309_robot.wav deleted file mode 100644 index 3e544c4..0000000 Binary files a/data/recordings/20260515_080309_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_080309_user.wav b/data/recordings/20260515_080309_user.wav deleted file mode 100644 index 3878685..0000000 Binary files a/data/recordings/20260515_080309_user.wav and /dev/null differ diff --git a/data/recordings/20260515_080319_robot.wav b/data/recordings/20260515_080319_robot.wav deleted file mode 100644 index 570a8a0..0000000 Binary files a/data/recordings/20260515_080319_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_080319_user.wav b/data/recordings/20260515_080319_user.wav deleted file mode 100644 index fcffc9b..0000000 Binary files a/data/recordings/20260515_080319_user.wav and /dev/null differ diff --git a/data/recordings/20260515_080329_user.wav b/data/recordings/20260515_080329_user.wav deleted file mode 100644 index 177461a..0000000 Binary files a/data/recordings/20260515_080329_user.wav and /dev/null differ diff --git a/data/recordings/20260515_080337_robot.wav b/data/recordings/20260515_080337_robot.wav deleted file mode 100644 index eba3423..0000000 Binary files a/data/recordings/20260515_080337_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_080337_user.wav b/data/recordings/20260515_080337_user.wav deleted file mode 100644 index befa4c7..0000000 Binary files a/data/recordings/20260515_080337_user.wav and /dev/null differ diff --git a/data/recordings/20260515_080341_robot.wav b/data/recordings/20260515_080341_robot.wav deleted file mode 100644 index b1f360e..0000000 Binary files a/data/recordings/20260515_080341_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_080341_user.wav b/data/recordings/20260515_080341_user.wav deleted file mode 100644 index 8612185..0000000 Binary files a/data/recordings/20260515_080341_user.wav and /dev/null differ diff --git a/data/recordings/20260515_080342_robot.wav b/data/recordings/20260515_080342_robot.wav deleted file mode 100644 index 830e66d..0000000 Binary files a/data/recordings/20260515_080342_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_080342_user.wav b/data/recordings/20260515_080342_user.wav deleted file mode 100644 index c30f33f..0000000 Binary files a/data/recordings/20260515_080342_user.wav and /dev/null differ diff --git a/data/recordings/20260515_080345_user.wav b/data/recordings/20260515_080345_user.wav deleted file mode 100644 index a045134..0000000 Binary files a/data/recordings/20260515_080345_user.wav and /dev/null differ diff --git a/data/recordings/20260515_080348_robot.wav b/data/recordings/20260515_080348_robot.wav deleted file mode 100644 index 6e6bc34..0000000 Binary files a/data/recordings/20260515_080348_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_080348_user.wav b/data/recordings/20260515_080348_user.wav deleted file mode 100644 index 457b4d7..0000000 Binary files a/data/recordings/20260515_080348_user.wav and /dev/null differ diff --git a/data/recordings/20260515_080401_robot.wav b/data/recordings/20260515_080401_robot.wav deleted file mode 100644 index 6cc2ef4..0000000 Binary files a/data/recordings/20260515_080401_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_080401_user.wav b/data/recordings/20260515_080401_user.wav deleted file mode 100644 index f31afd3..0000000 Binary files a/data/recordings/20260515_080401_user.wav and /dev/null differ diff --git a/data/recordings/20260515_080433_robot.wav b/data/recordings/20260515_080433_robot.wav deleted file mode 100644 index 3b78630..0000000 Binary files a/data/recordings/20260515_080433_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_080438_robot.wav b/data/recordings/20260515_080438_robot.wav deleted file mode 100644 index e5149bf..0000000 Binary files a/data/recordings/20260515_080438_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_080438_user.wav b/data/recordings/20260515_080438_user.wav deleted file mode 100644 index ca1a3e5..0000000 Binary files a/data/recordings/20260515_080438_user.wav and /dev/null differ diff --git a/data/recordings/20260515_080519_robot.wav b/data/recordings/20260515_080519_robot.wav deleted file mode 100644 index 52d09b1..0000000 Binary files a/data/recordings/20260515_080519_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_080519_user.wav b/data/recordings/20260515_080519_user.wav deleted file mode 100644 index 60cbd1a..0000000 Binary files a/data/recordings/20260515_080519_user.wav and /dev/null differ diff --git a/data/recordings/20260515_080555_user.wav b/data/recordings/20260515_080555_user.wav deleted file mode 100644 index dd567e1..0000000 Binary files a/data/recordings/20260515_080555_user.wav and /dev/null differ diff --git a/data/recordings/20260515_080615_robot.wav b/data/recordings/20260515_080615_robot.wav deleted file mode 100644 index 949cbe2..0000000 Binary files a/data/recordings/20260515_080615_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_080615_user.wav b/data/recordings/20260515_080615_user.wav deleted file mode 100644 index ffa1afb..0000000 Binary files a/data/recordings/20260515_080615_user.wav and /dev/null differ diff --git a/data/recordings/20260515_080626_robot.wav b/data/recordings/20260515_080626_robot.wav deleted file mode 100644 index d7c3dbd..0000000 Binary files a/data/recordings/20260515_080626_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_080626_user.wav b/data/recordings/20260515_080626_user.wav deleted file mode 100644 index 177c258..0000000 Binary files a/data/recordings/20260515_080626_user.wav and /dev/null differ diff --git a/data/recordings/20260515_080633_robot.wav b/data/recordings/20260515_080633_robot.wav deleted file mode 100644 index 6fb7bad..0000000 Binary files a/data/recordings/20260515_080633_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_080633_user.wav b/data/recordings/20260515_080633_user.wav deleted file mode 100644 index 45fd83a..0000000 Binary files a/data/recordings/20260515_080633_user.wav and /dev/null differ diff --git a/data/recordings/20260515_081625_robot.wav b/data/recordings/20260515_081625_robot.wav deleted file mode 100644 index 7d04c69..0000000 Binary files a/data/recordings/20260515_081625_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_081915_robot.wav b/data/recordings/20260515_081915_robot.wav deleted file mode 100644 index 2affc95..0000000 Binary files a/data/recordings/20260515_081915_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_081919_robot.wav b/data/recordings/20260515_081919_robot.wav deleted file mode 100644 index 4a5ae2d..0000000 Binary files a/data/recordings/20260515_081919_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_081919_user.wav b/data/recordings/20260515_081919_user.wav deleted file mode 100644 index e86a31b..0000000 Binary files a/data/recordings/20260515_081919_user.wav and /dev/null differ diff --git a/data/recordings/20260515_081926_robot.wav b/data/recordings/20260515_081926_robot.wav deleted file mode 100644 index 9291859..0000000 Binary files a/data/recordings/20260515_081926_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_081926_user.wav b/data/recordings/20260515_081926_user.wav deleted file mode 100644 index b5af554..0000000 Binary files a/data/recordings/20260515_081926_user.wav and /dev/null differ diff --git a/data/recordings/20260515_081931_robot.wav b/data/recordings/20260515_081931_robot.wav deleted file mode 100644 index d8b024b..0000000 Binary files a/data/recordings/20260515_081931_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_081931_user.wav b/data/recordings/20260515_081931_user.wav deleted file mode 100644 index cb1ef22..0000000 Binary files a/data/recordings/20260515_081931_user.wav and /dev/null differ diff --git a/data/recordings/20260515_081937_robot.wav b/data/recordings/20260515_081937_robot.wav deleted file mode 100644 index 9675886..0000000 Binary files a/data/recordings/20260515_081937_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_081937_user.wav b/data/recordings/20260515_081937_user.wav deleted file mode 100644 index b7e56c5..0000000 Binary files a/data/recordings/20260515_081937_user.wav and /dev/null differ diff --git a/data/recordings/20260515_081949_robot.wav b/data/recordings/20260515_081949_robot.wav deleted file mode 100644 index cd8b53b..0000000 Binary files a/data/recordings/20260515_081949_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_081949_user.wav b/data/recordings/20260515_081949_user.wav deleted file mode 100644 index 65d7346..0000000 Binary files a/data/recordings/20260515_081949_user.wav and /dev/null differ diff --git a/data/recordings/20260515_082011_robot.wav b/data/recordings/20260515_082011_robot.wav deleted file mode 100644 index c387d32..0000000 Binary files a/data/recordings/20260515_082011_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_082011_user.wav b/data/recordings/20260515_082011_user.wav deleted file mode 100644 index 219b5e8..0000000 Binary files a/data/recordings/20260515_082011_user.wav and /dev/null differ diff --git a/data/recordings/20260515_082024_robot.wav b/data/recordings/20260515_082024_robot.wav deleted file mode 100644 index 419f45b..0000000 Binary files a/data/recordings/20260515_082024_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_082024_user.wav b/data/recordings/20260515_082024_user.wav deleted file mode 100644 index c94c444..0000000 Binary files a/data/recordings/20260515_082024_user.wav and /dev/null differ diff --git a/data/recordings/20260515_083239_robot.wav b/data/recordings/20260515_083239_robot.wav deleted file mode 100644 index b10b8c3..0000000 Binary files a/data/recordings/20260515_083239_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_083245_robot.wav b/data/recordings/20260515_083245_robot.wav deleted file mode 100644 index 26cafe3..0000000 Binary files a/data/recordings/20260515_083245_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_083245_user.wav b/data/recordings/20260515_083245_user.wav deleted file mode 100644 index f2fc8b7..0000000 Binary files a/data/recordings/20260515_083245_user.wav and /dev/null differ diff --git a/data/recordings/20260515_083253_robot.wav b/data/recordings/20260515_083253_robot.wav deleted file mode 100644 index 4236cb5..0000000 Binary files a/data/recordings/20260515_083253_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_083253_user.wav b/data/recordings/20260515_083253_user.wav deleted file mode 100644 index 188f701..0000000 Binary files a/data/recordings/20260515_083253_user.wav and /dev/null differ diff --git a/data/recordings/20260515_083300_robot.wav b/data/recordings/20260515_083300_robot.wav deleted file mode 100644 index f300956..0000000 Binary files a/data/recordings/20260515_083300_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_083300_user.wav b/data/recordings/20260515_083300_user.wav deleted file mode 100644 index 01e6ad6..0000000 Binary files a/data/recordings/20260515_083300_user.wav and /dev/null differ diff --git a/data/recordings/20260515_083318_robot.wav b/data/recordings/20260515_083318_robot.wav deleted file mode 100644 index cd1fbbf..0000000 Binary files a/data/recordings/20260515_083318_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_083318_user.wav b/data/recordings/20260515_083318_user.wav deleted file mode 100644 index 5d0bf36..0000000 Binary files a/data/recordings/20260515_083318_user.wav and /dev/null differ diff --git a/data/recordings/20260515_083329_user.wav b/data/recordings/20260515_083329_user.wav deleted file mode 100644 index 5a4a742..0000000 Binary files a/data/recordings/20260515_083329_user.wav and /dev/null differ diff --git a/data/recordings/20260515_083330_robot.wav b/data/recordings/20260515_083330_robot.wav deleted file mode 100644 index e181240..0000000 Binary files a/data/recordings/20260515_083330_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_083330_user.wav b/data/recordings/20260515_083330_user.wav deleted file mode 100644 index dc5fb64..0000000 Binary files a/data/recordings/20260515_083330_user.wav and /dev/null differ diff --git a/data/recordings/20260515_083338_robot.wav b/data/recordings/20260515_083338_robot.wav deleted file mode 100644 index f0f9dc7..0000000 Binary files a/data/recordings/20260515_083338_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_083338_user.wav b/data/recordings/20260515_083338_user.wav deleted file mode 100644 index 7f8f5a9..0000000 Binary files a/data/recordings/20260515_083338_user.wav and /dev/null differ diff --git a/data/recordings/20260515_083345_user.wav b/data/recordings/20260515_083345_user.wav deleted file mode 100644 index aecec25..0000000 Binary files a/data/recordings/20260515_083345_user.wav and /dev/null differ diff --git a/data/recordings/20260515_083348_user.wav b/data/recordings/20260515_083348_user.wav deleted file mode 100644 index 91601bd..0000000 Binary files a/data/recordings/20260515_083348_user.wav and /dev/null differ diff --git a/data/recordings/20260515_083417_robot.wav b/data/recordings/20260515_083417_robot.wav deleted file mode 100644 index a1144dc..0000000 Binary files a/data/recordings/20260515_083417_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_083421_user.wav b/data/recordings/20260515_083421_user.wav deleted file mode 100644 index fddaaa6..0000000 Binary files a/data/recordings/20260515_083421_user.wav and /dev/null differ diff --git a/data/recordings/20260515_083434_user.wav b/data/recordings/20260515_083434_user.wav deleted file mode 100644 index eb1c220..0000000 Binary files a/data/recordings/20260515_083434_user.wav and /dev/null differ diff --git a/data/recordings/20260515_083435_robot.wav b/data/recordings/20260515_083435_robot.wav deleted file mode 100644 index 0f43366..0000000 Binary files a/data/recordings/20260515_083435_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_083435_user.wav b/data/recordings/20260515_083435_user.wav deleted file mode 100644 index 22c5510..0000000 Binary files a/data/recordings/20260515_083435_user.wav and /dev/null differ diff --git a/data/recordings/20260515_083446_robot.wav b/data/recordings/20260515_083446_robot.wav deleted file mode 100644 index e045e99..0000000 Binary files a/data/recordings/20260515_083446_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_083446_user.wav b/data/recordings/20260515_083446_user.wav deleted file mode 100644 index eb07aa7..0000000 Binary files a/data/recordings/20260515_083446_user.wav and /dev/null differ diff --git a/data/recordings/20260515_083454_robot.wav b/data/recordings/20260515_083454_robot.wav deleted file mode 100644 index 85ba519..0000000 Binary files a/data/recordings/20260515_083454_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_083454_user.wav b/data/recordings/20260515_083454_user.wav deleted file mode 100644 index 8ee11fc..0000000 Binary files a/data/recordings/20260515_083454_user.wav and /dev/null differ diff --git a/data/recordings/20260515_084552_robot.wav b/data/recordings/20260515_084552_robot.wav deleted file mode 100644 index 31bcd77..0000000 Binary files a/data/recordings/20260515_084552_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_092655_robot.wav b/data/recordings/20260515_092655_robot.wav deleted file mode 100644 index 9a231a1..0000000 Binary files a/data/recordings/20260515_092655_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_092655_user.wav b/data/recordings/20260515_092655_user.wav deleted file mode 100644 index dbff413..0000000 Binary files a/data/recordings/20260515_092655_user.wav and /dev/null differ diff --git a/data/recordings/20260515_092658_robot.wav b/data/recordings/20260515_092658_robot.wav deleted file mode 100644 index e66bda0..0000000 Binary files a/data/recordings/20260515_092658_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_092658_user.wav b/data/recordings/20260515_092658_user.wav deleted file mode 100644 index 64df9a6..0000000 Binary files a/data/recordings/20260515_092658_user.wav and /dev/null differ diff --git a/data/recordings/20260515_092659_user.wav b/data/recordings/20260515_092659_user.wav deleted file mode 100644 index 3fb9f45..0000000 Binary files a/data/recordings/20260515_092659_user.wav and /dev/null differ diff --git a/data/recordings/20260515_092702_robot.wav b/data/recordings/20260515_092702_robot.wav deleted file mode 100644 index 9b93a82..0000000 Binary files a/data/recordings/20260515_092702_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_092702_user.wav b/data/recordings/20260515_092702_user.wav deleted file mode 100644 index 18780a9..0000000 Binary files a/data/recordings/20260515_092702_user.wav and /dev/null differ diff --git a/data/recordings/20260515_092713_robot.wav b/data/recordings/20260515_092713_robot.wav deleted file mode 100644 index 654b39a..0000000 Binary files a/data/recordings/20260515_092713_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_092713_user.wav b/data/recordings/20260515_092713_user.wav deleted file mode 100644 index 213aec5..0000000 Binary files a/data/recordings/20260515_092713_user.wav and /dev/null differ diff --git a/data/recordings/20260515_092725_user.wav b/data/recordings/20260515_092725_user.wav deleted file mode 100644 index 0b2fba3..0000000 Binary files a/data/recordings/20260515_092725_user.wav and /dev/null differ diff --git a/data/recordings/20260515_092727_robot.wav b/data/recordings/20260515_092727_robot.wav deleted file mode 100644 index 558ff81..0000000 Binary files a/data/recordings/20260515_092727_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_092727_user.wav b/data/recordings/20260515_092727_user.wav deleted file mode 100644 index ae4f4ec..0000000 Binary files a/data/recordings/20260515_092727_user.wav and /dev/null differ diff --git a/data/recordings/20260515_092732_robot.wav b/data/recordings/20260515_092732_robot.wav deleted file mode 100644 index 4c1abf1..0000000 Binary files a/data/recordings/20260515_092732_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_092732_user.wav b/data/recordings/20260515_092732_user.wav deleted file mode 100644 index 48adb28..0000000 Binary files a/data/recordings/20260515_092732_user.wav and /dev/null differ diff --git a/data/recordings/20260515_092733_user.wav b/data/recordings/20260515_092733_user.wav deleted file mode 100644 index 9329bb1..0000000 Binary files a/data/recordings/20260515_092733_user.wav and /dev/null differ diff --git a/data/recordings/20260515_092741_robot.wav b/data/recordings/20260515_092741_robot.wav deleted file mode 100644 index 29461a5..0000000 Binary files a/data/recordings/20260515_092741_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_092741_user.wav b/data/recordings/20260515_092741_user.wav deleted file mode 100644 index 7fb82ed..0000000 Binary files a/data/recordings/20260515_092741_user.wav and /dev/null differ diff --git a/data/recordings/20260515_092752_robot.wav b/data/recordings/20260515_092752_robot.wav deleted file mode 100644 index dab38b0..0000000 Binary files a/data/recordings/20260515_092752_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_092752_user.wav b/data/recordings/20260515_092752_user.wav deleted file mode 100644 index d9c922e..0000000 Binary files a/data/recordings/20260515_092752_user.wav and /dev/null differ diff --git a/data/recordings/20260515_092806_robot.wav b/data/recordings/20260515_092806_robot.wav deleted file mode 100644 index f4d0cfb..0000000 Binary files a/data/recordings/20260515_092806_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_092806_user.wav b/data/recordings/20260515_092806_user.wav deleted file mode 100644 index 1e28081..0000000 Binary files a/data/recordings/20260515_092806_user.wav and /dev/null differ diff --git a/data/recordings/20260515_092814_user.wav b/data/recordings/20260515_092814_user.wav deleted file mode 100644 index a26e8a3..0000000 Binary files a/data/recordings/20260515_092814_user.wav and /dev/null differ diff --git a/data/recordings/20260515_092840_user.wav b/data/recordings/20260515_092840_user.wav deleted file mode 100644 index ab3aefe..0000000 Binary files a/data/recordings/20260515_092840_user.wav and /dev/null differ diff --git a/data/recordings/20260515_092846_user.wav b/data/recordings/20260515_092846_user.wav deleted file mode 100644 index b362d4e..0000000 Binary files a/data/recordings/20260515_092846_user.wav and /dev/null differ diff --git a/data/recordings/20260515_092853_user.wav b/data/recordings/20260515_092853_user.wav deleted file mode 100644 index 8956dbe..0000000 Binary files a/data/recordings/20260515_092853_user.wav and /dev/null differ diff --git a/data/recordings/20260515_092900_robot.wav b/data/recordings/20260515_092900_robot.wav deleted file mode 100644 index ad696be..0000000 Binary files a/data/recordings/20260515_092900_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_092900_user.wav b/data/recordings/20260515_092900_user.wav deleted file mode 100644 index c0393b0..0000000 Binary files a/data/recordings/20260515_092900_user.wav and /dev/null differ diff --git a/data/recordings/20260515_092910_user.wav b/data/recordings/20260515_092910_user.wav deleted file mode 100644 index 29da635..0000000 Binary files a/data/recordings/20260515_092910_user.wav and /dev/null differ diff --git a/data/recordings/20260515_092915_user.wav b/data/recordings/20260515_092915_user.wav deleted file mode 100644 index 77ab4c6..0000000 Binary files a/data/recordings/20260515_092915_user.wav and /dev/null differ diff --git a/data/recordings/20260515_092919_robot.wav b/data/recordings/20260515_092919_robot.wav deleted file mode 100644 index 6a3a461..0000000 Binary files a/data/recordings/20260515_092919_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_092919_user.wav b/data/recordings/20260515_092919_user.wav deleted file mode 100644 index bbff718..0000000 Binary files a/data/recordings/20260515_092919_user.wav and /dev/null differ diff --git a/data/recordings/20260515_092931_robot.wav b/data/recordings/20260515_092931_robot.wav deleted file mode 100644 index 950dd6b..0000000 Binary files a/data/recordings/20260515_092931_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_092931_user.wav b/data/recordings/20260515_092931_user.wav deleted file mode 100644 index 32c4977..0000000 Binary files a/data/recordings/20260515_092931_user.wav and /dev/null differ diff --git a/data/recordings/20260515_092942_user.wav b/data/recordings/20260515_092942_user.wav deleted file mode 100644 index 9c089cf..0000000 Binary files a/data/recordings/20260515_092942_user.wav and /dev/null differ diff --git a/data/recordings/20260515_092945_robot.wav b/data/recordings/20260515_092945_robot.wav deleted file mode 100644 index e9f31b2..0000000 Binary files a/data/recordings/20260515_092945_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_092945_user.wav b/data/recordings/20260515_092945_user.wav deleted file mode 100644 index c0078b3..0000000 Binary files a/data/recordings/20260515_092945_user.wav and /dev/null differ diff --git a/data/recordings/20260515_092958_user.wav b/data/recordings/20260515_092958_user.wav deleted file mode 100644 index a512240..0000000 Binary files a/data/recordings/20260515_092958_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093001_user.wav b/data/recordings/20260515_093001_user.wav deleted file mode 100644 index 4aaa857..0000000 Binary files a/data/recordings/20260515_093001_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093002_user.wav b/data/recordings/20260515_093002_user.wav deleted file mode 100644 index adb563e..0000000 Binary files a/data/recordings/20260515_093002_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093006_robot.wav b/data/recordings/20260515_093006_robot.wav deleted file mode 100644 index 0f48107..0000000 Binary files a/data/recordings/20260515_093006_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093006_user.wav b/data/recordings/20260515_093006_user.wav deleted file mode 100644 index 9dcc598..0000000 Binary files a/data/recordings/20260515_093006_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093008_robot.wav b/data/recordings/20260515_093008_robot.wav deleted file mode 100644 index 91313d3..0000000 Binary files a/data/recordings/20260515_093008_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093008_user.wav b/data/recordings/20260515_093008_user.wav deleted file mode 100644 index 23295b0..0000000 Binary files a/data/recordings/20260515_093008_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093010_user.wav b/data/recordings/20260515_093010_user.wav deleted file mode 100644 index 7ed7ff1..0000000 Binary files a/data/recordings/20260515_093010_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093011_user.wav b/data/recordings/20260515_093011_user.wav deleted file mode 100644 index 226f9c9..0000000 Binary files a/data/recordings/20260515_093011_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093012_robot.wav b/data/recordings/20260515_093012_robot.wav deleted file mode 100644 index b8fe0ff..0000000 Binary files a/data/recordings/20260515_093012_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093012_user.wav b/data/recordings/20260515_093012_user.wav deleted file mode 100644 index 41c8f86..0000000 Binary files a/data/recordings/20260515_093012_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093019_user.wav b/data/recordings/20260515_093019_user.wav deleted file mode 100644 index e12b4ac..0000000 Binary files a/data/recordings/20260515_093019_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093021_robot.wav b/data/recordings/20260515_093021_robot.wav deleted file mode 100644 index 58eef1e..0000000 Binary files a/data/recordings/20260515_093021_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093021_user.wav b/data/recordings/20260515_093021_user.wav deleted file mode 100644 index b9818ee..0000000 Binary files a/data/recordings/20260515_093021_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093029_user.wav b/data/recordings/20260515_093029_user.wav deleted file mode 100644 index 5783c21..0000000 Binary files a/data/recordings/20260515_093029_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093030_robot.wav b/data/recordings/20260515_093030_robot.wav deleted file mode 100644 index b63bb33..0000000 Binary files a/data/recordings/20260515_093030_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093030_user.wav b/data/recordings/20260515_093030_user.wav deleted file mode 100644 index 8728c38..0000000 Binary files a/data/recordings/20260515_093030_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093040_robot.wav b/data/recordings/20260515_093040_robot.wav deleted file mode 100644 index 9597ca2..0000000 Binary files a/data/recordings/20260515_093040_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093040_user.wav b/data/recordings/20260515_093040_user.wav deleted file mode 100644 index f0a119b..0000000 Binary files a/data/recordings/20260515_093040_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093106_robot.wav b/data/recordings/20260515_093106_robot.wav deleted file mode 100644 index afcd30f..0000000 Binary files a/data/recordings/20260515_093106_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093106_user.wav b/data/recordings/20260515_093106_user.wav deleted file mode 100644 index f261704..0000000 Binary files a/data/recordings/20260515_093106_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093114_user.wav b/data/recordings/20260515_093114_user.wav deleted file mode 100644 index 8a7733f..0000000 Binary files a/data/recordings/20260515_093114_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093120_robot.wav b/data/recordings/20260515_093120_robot.wav deleted file mode 100644 index 080ab9a..0000000 Binary files a/data/recordings/20260515_093120_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093120_user.wav b/data/recordings/20260515_093120_user.wav deleted file mode 100644 index 4458fd7..0000000 Binary files a/data/recordings/20260515_093120_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093126_user.wav b/data/recordings/20260515_093126_user.wav deleted file mode 100644 index e425258..0000000 Binary files a/data/recordings/20260515_093126_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093129_robot.wav b/data/recordings/20260515_093129_robot.wav deleted file mode 100644 index 99b8fb3..0000000 Binary files a/data/recordings/20260515_093129_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093129_user.wav b/data/recordings/20260515_093129_user.wav deleted file mode 100644 index ad951d2..0000000 Binary files a/data/recordings/20260515_093129_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093133_robot.wav b/data/recordings/20260515_093133_robot.wav deleted file mode 100644 index a6683e3..0000000 Binary files a/data/recordings/20260515_093133_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093133_user.wav b/data/recordings/20260515_093133_user.wav deleted file mode 100644 index dcdd56a..0000000 Binary files a/data/recordings/20260515_093133_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093137_user.wav b/data/recordings/20260515_093137_user.wav deleted file mode 100644 index 8ab6d6d..0000000 Binary files a/data/recordings/20260515_093137_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093139_robot.wav b/data/recordings/20260515_093139_robot.wav deleted file mode 100644 index ab27578..0000000 Binary files a/data/recordings/20260515_093139_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093139_user.wav b/data/recordings/20260515_093139_user.wav deleted file mode 100644 index 90859ec..0000000 Binary files a/data/recordings/20260515_093139_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093144_user.wav b/data/recordings/20260515_093144_user.wav deleted file mode 100644 index 59cb2c2..0000000 Binary files a/data/recordings/20260515_093144_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093146_user.wav b/data/recordings/20260515_093146_user.wav deleted file mode 100644 index 5d36f1f..0000000 Binary files a/data/recordings/20260515_093146_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093149_robot.wav b/data/recordings/20260515_093149_robot.wav deleted file mode 100644 index 38de491..0000000 Binary files a/data/recordings/20260515_093149_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093149_user.wav b/data/recordings/20260515_093149_user.wav deleted file mode 100644 index 5baada5..0000000 Binary files a/data/recordings/20260515_093149_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093151_robot.wav b/data/recordings/20260515_093151_robot.wav deleted file mode 100644 index 9cf98e8..0000000 Binary files a/data/recordings/20260515_093151_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093151_user.wav b/data/recordings/20260515_093151_user.wav deleted file mode 100644 index 0f078fd..0000000 Binary files a/data/recordings/20260515_093151_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093152_user.wav b/data/recordings/20260515_093152_user.wav deleted file mode 100644 index 8c9a49b..0000000 Binary files a/data/recordings/20260515_093152_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093154_robot.wav b/data/recordings/20260515_093154_robot.wav deleted file mode 100644 index 1ead35b..0000000 Binary files a/data/recordings/20260515_093154_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093154_user.wav b/data/recordings/20260515_093154_user.wav deleted file mode 100644 index 7039382..0000000 Binary files a/data/recordings/20260515_093154_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093157_robot.wav b/data/recordings/20260515_093157_robot.wav deleted file mode 100644 index 8c1a78c..0000000 Binary files a/data/recordings/20260515_093157_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093157_user.wav b/data/recordings/20260515_093157_user.wav deleted file mode 100644 index bd69077..0000000 Binary files a/data/recordings/20260515_093157_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093204_robot.wav b/data/recordings/20260515_093204_robot.wav deleted file mode 100644 index 6d07e2a..0000000 Binary files a/data/recordings/20260515_093204_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093204_user.wav b/data/recordings/20260515_093204_user.wav deleted file mode 100644 index 3f1dbfc..0000000 Binary files a/data/recordings/20260515_093204_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093215_robot.wav b/data/recordings/20260515_093215_robot.wav deleted file mode 100644 index 0e2720a..0000000 Binary files a/data/recordings/20260515_093215_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093215_user.wav b/data/recordings/20260515_093215_user.wav deleted file mode 100644 index 3f7694c..0000000 Binary files a/data/recordings/20260515_093215_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093225_user.wav b/data/recordings/20260515_093225_user.wav deleted file mode 100644 index 064ea31..0000000 Binary files a/data/recordings/20260515_093225_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093226_robot.wav b/data/recordings/20260515_093226_robot.wav deleted file mode 100644 index 5e4b977..0000000 Binary files a/data/recordings/20260515_093226_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093226_user.wav b/data/recordings/20260515_093226_user.wav deleted file mode 100644 index cc584a9..0000000 Binary files a/data/recordings/20260515_093226_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093234_user.wav b/data/recordings/20260515_093234_user.wav deleted file mode 100644 index fbc68a7..0000000 Binary files a/data/recordings/20260515_093234_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093236_user.wav b/data/recordings/20260515_093236_user.wav deleted file mode 100644 index 534f150..0000000 Binary files a/data/recordings/20260515_093236_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093239_robot.wav b/data/recordings/20260515_093239_robot.wav deleted file mode 100644 index 582efba..0000000 Binary files a/data/recordings/20260515_093239_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093239_user.wav b/data/recordings/20260515_093239_user.wav deleted file mode 100644 index ec7f091..0000000 Binary files a/data/recordings/20260515_093239_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093245_user.wav b/data/recordings/20260515_093245_user.wav deleted file mode 100644 index 3e67579..0000000 Binary files a/data/recordings/20260515_093245_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093246_user.wav b/data/recordings/20260515_093246_user.wav deleted file mode 100644 index ce255bc..0000000 Binary files a/data/recordings/20260515_093246_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093258_robot.wav b/data/recordings/20260515_093258_robot.wav deleted file mode 100644 index 69a4734..0000000 Binary files a/data/recordings/20260515_093258_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093258_user.wav b/data/recordings/20260515_093258_user.wav deleted file mode 100644 index dae978d..0000000 Binary files a/data/recordings/20260515_093258_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093303_user.wav b/data/recordings/20260515_093303_user.wav deleted file mode 100644 index e9bd655..0000000 Binary files a/data/recordings/20260515_093303_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093304_user.wav b/data/recordings/20260515_093304_user.wav deleted file mode 100644 index f40fb5d..0000000 Binary files a/data/recordings/20260515_093304_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093307_user.wav b/data/recordings/20260515_093307_user.wav deleted file mode 100644 index e2ab1af..0000000 Binary files a/data/recordings/20260515_093307_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093311_user.wav b/data/recordings/20260515_093311_user.wav deleted file mode 100644 index 7816a43..0000000 Binary files a/data/recordings/20260515_093311_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093314_robot.wav b/data/recordings/20260515_093314_robot.wav deleted file mode 100644 index 048c0b2..0000000 Binary files a/data/recordings/20260515_093314_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093314_user.wav b/data/recordings/20260515_093314_user.wav deleted file mode 100644 index b07cac5..0000000 Binary files a/data/recordings/20260515_093314_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093322_robot.wav b/data/recordings/20260515_093322_robot.wav deleted file mode 100644 index 19f969b..0000000 Binary files a/data/recordings/20260515_093322_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093322_user.wav b/data/recordings/20260515_093322_user.wav deleted file mode 100644 index 982e731..0000000 Binary files a/data/recordings/20260515_093322_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093330_robot.wav b/data/recordings/20260515_093330_robot.wav deleted file mode 100644 index 398a52e..0000000 Binary files a/data/recordings/20260515_093330_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093330_user.wav b/data/recordings/20260515_093330_user.wav deleted file mode 100644 index 7478780..0000000 Binary files a/data/recordings/20260515_093330_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093332_user.wav b/data/recordings/20260515_093332_user.wav deleted file mode 100644 index 8a9f8a6..0000000 Binary files a/data/recordings/20260515_093332_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093334_robot.wav b/data/recordings/20260515_093334_robot.wav deleted file mode 100644 index 9b1633b..0000000 Binary files a/data/recordings/20260515_093334_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093334_user.wav b/data/recordings/20260515_093334_user.wav deleted file mode 100644 index 1ef2944..0000000 Binary files a/data/recordings/20260515_093334_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093357_robot.wav b/data/recordings/20260515_093357_robot.wav deleted file mode 100644 index 3f203f8..0000000 Binary files a/data/recordings/20260515_093357_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093357_user.wav b/data/recordings/20260515_093357_user.wav deleted file mode 100644 index c81702b..0000000 Binary files a/data/recordings/20260515_093357_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093402_user.wav b/data/recordings/20260515_093402_user.wav deleted file mode 100644 index 3276336..0000000 Binary files a/data/recordings/20260515_093402_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093410_user.wav b/data/recordings/20260515_093410_user.wav deleted file mode 100644 index cf75ebc..0000000 Binary files a/data/recordings/20260515_093410_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093413_user.wav b/data/recordings/20260515_093413_user.wav deleted file mode 100644 index 36f95aa..0000000 Binary files a/data/recordings/20260515_093413_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093415_user.wav b/data/recordings/20260515_093415_user.wav deleted file mode 100644 index 3ee5c96..0000000 Binary files a/data/recordings/20260515_093415_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093422_robot.wav b/data/recordings/20260515_093422_robot.wav deleted file mode 100644 index 589484a..0000000 Binary files a/data/recordings/20260515_093422_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093422_user.wav b/data/recordings/20260515_093422_user.wav deleted file mode 100644 index 2b73f23..0000000 Binary files a/data/recordings/20260515_093422_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093429_user.wav b/data/recordings/20260515_093429_user.wav deleted file mode 100644 index f9be38a..0000000 Binary files a/data/recordings/20260515_093429_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093435_user.wav b/data/recordings/20260515_093435_user.wav deleted file mode 100644 index 6de230a..0000000 Binary files a/data/recordings/20260515_093435_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093442_user.wav b/data/recordings/20260515_093442_user.wav deleted file mode 100644 index 0c6e9d5..0000000 Binary files a/data/recordings/20260515_093442_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093450_robot.wav b/data/recordings/20260515_093450_robot.wav deleted file mode 100644 index 81ea3d3..0000000 Binary files a/data/recordings/20260515_093450_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093450_user.wav b/data/recordings/20260515_093450_user.wav deleted file mode 100644 index 9c50bc1..0000000 Binary files a/data/recordings/20260515_093450_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093454_user.wav b/data/recordings/20260515_093454_user.wav deleted file mode 100644 index 7f4d0ba..0000000 Binary files a/data/recordings/20260515_093454_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093456_user.wav b/data/recordings/20260515_093456_user.wav deleted file mode 100644 index 54d1f7c..0000000 Binary files a/data/recordings/20260515_093456_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093519_robot.wav b/data/recordings/20260515_093519_robot.wav deleted file mode 100644 index 04b4649..0000000 Binary files a/data/recordings/20260515_093519_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093519_user.wav b/data/recordings/20260515_093519_user.wav deleted file mode 100644 index a63dfb5..0000000 Binary files a/data/recordings/20260515_093519_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093525_user.wav b/data/recordings/20260515_093525_user.wav deleted file mode 100644 index b69a548..0000000 Binary files a/data/recordings/20260515_093525_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093526_user.wav b/data/recordings/20260515_093526_user.wav deleted file mode 100644 index 887d9af..0000000 Binary files a/data/recordings/20260515_093526_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093537_user.wav b/data/recordings/20260515_093537_user.wav deleted file mode 100644 index 1187b15..0000000 Binary files a/data/recordings/20260515_093537_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093542_user.wav b/data/recordings/20260515_093542_user.wav deleted file mode 100644 index cdbe402..0000000 Binary files a/data/recordings/20260515_093542_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093551_user.wav b/data/recordings/20260515_093551_user.wav deleted file mode 100644 index 82a29c3..0000000 Binary files a/data/recordings/20260515_093551_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093554_robot.wav b/data/recordings/20260515_093554_robot.wav deleted file mode 100644 index 429b526..0000000 Binary files a/data/recordings/20260515_093554_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093554_user.wav b/data/recordings/20260515_093554_user.wav deleted file mode 100644 index 37b9484..0000000 Binary files a/data/recordings/20260515_093554_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093601_robot.wav b/data/recordings/20260515_093601_robot.wav deleted file mode 100644 index b0caf52..0000000 Binary files a/data/recordings/20260515_093601_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093601_user.wav b/data/recordings/20260515_093601_user.wav deleted file mode 100644 index 0a1fb6b..0000000 Binary files a/data/recordings/20260515_093601_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093602_robot.wav b/data/recordings/20260515_093602_robot.wav deleted file mode 100644 index 3fb955c..0000000 Binary files a/data/recordings/20260515_093602_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093602_user.wav b/data/recordings/20260515_093602_user.wav deleted file mode 100644 index d73bf00..0000000 Binary files a/data/recordings/20260515_093602_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093603_user.wav b/data/recordings/20260515_093603_user.wav deleted file mode 100644 index d720ea0..0000000 Binary files a/data/recordings/20260515_093603_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093607_robot.wav b/data/recordings/20260515_093607_robot.wav deleted file mode 100644 index a2a903f..0000000 Binary files a/data/recordings/20260515_093607_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093607_user.wav b/data/recordings/20260515_093607_user.wav deleted file mode 100644 index 6e89433..0000000 Binary files a/data/recordings/20260515_093607_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093618_user.wav b/data/recordings/20260515_093618_user.wav deleted file mode 100644 index 7326f59..0000000 Binary files a/data/recordings/20260515_093618_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093620_user.wav b/data/recordings/20260515_093620_user.wav deleted file mode 100644 index 746369f..0000000 Binary files a/data/recordings/20260515_093620_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093703_robot.wav b/data/recordings/20260515_093703_robot.wav deleted file mode 100644 index 40bc4da..0000000 Binary files a/data/recordings/20260515_093703_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093703_user.wav b/data/recordings/20260515_093703_user.wav deleted file mode 100644 index 13aa386..0000000 Binary files a/data/recordings/20260515_093703_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093707_robot.wav b/data/recordings/20260515_093707_robot.wav deleted file mode 100644 index 9420900..0000000 Binary files a/data/recordings/20260515_093707_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093707_user.wav b/data/recordings/20260515_093707_user.wav deleted file mode 100644 index 10d2b31..0000000 Binary files a/data/recordings/20260515_093707_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093711_robot.wav b/data/recordings/20260515_093711_robot.wav deleted file mode 100644 index 5033dac..0000000 Binary files a/data/recordings/20260515_093711_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093711_user.wav b/data/recordings/20260515_093711_user.wav deleted file mode 100644 index 9b41ca1..0000000 Binary files a/data/recordings/20260515_093711_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093715_user.wav b/data/recordings/20260515_093715_user.wav deleted file mode 100644 index 0e1ed5f..0000000 Binary files a/data/recordings/20260515_093715_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093729_robot.wav b/data/recordings/20260515_093729_robot.wav deleted file mode 100644 index e41353e..0000000 Binary files a/data/recordings/20260515_093729_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093729_user.wav b/data/recordings/20260515_093729_user.wav deleted file mode 100644 index f830ab1..0000000 Binary files a/data/recordings/20260515_093729_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093735_robot.wav b/data/recordings/20260515_093735_robot.wav deleted file mode 100644 index f535ddd..0000000 Binary files a/data/recordings/20260515_093735_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093735_user.wav b/data/recordings/20260515_093735_user.wav deleted file mode 100644 index 8699de5..0000000 Binary files a/data/recordings/20260515_093735_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093808_robot.wav b/data/recordings/20260515_093808_robot.wav deleted file mode 100644 index 6eb5ff2..0000000 Binary files a/data/recordings/20260515_093808_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093808_user.wav b/data/recordings/20260515_093808_user.wav deleted file mode 100644 index 5ac915a..0000000 Binary files a/data/recordings/20260515_093808_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093812_robot.wav b/data/recordings/20260515_093812_robot.wav deleted file mode 100644 index 5a5e12b..0000000 Binary files a/data/recordings/20260515_093812_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093812_user.wav b/data/recordings/20260515_093812_user.wav deleted file mode 100644 index 993d3e8..0000000 Binary files a/data/recordings/20260515_093812_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093820_user.wav b/data/recordings/20260515_093820_user.wav deleted file mode 100644 index 74d7995..0000000 Binary files a/data/recordings/20260515_093820_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093833_robot.wav b/data/recordings/20260515_093833_robot.wav deleted file mode 100644 index 5c11c7f..0000000 Binary files a/data/recordings/20260515_093833_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093833_user.wav b/data/recordings/20260515_093833_user.wav deleted file mode 100644 index 11b871c..0000000 Binary files a/data/recordings/20260515_093833_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093838_robot.wav b/data/recordings/20260515_093838_robot.wav deleted file mode 100644 index 4eb8928..0000000 Binary files a/data/recordings/20260515_093838_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093838_user.wav b/data/recordings/20260515_093838_user.wav deleted file mode 100644 index d63e136..0000000 Binary files a/data/recordings/20260515_093838_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093841_user.wav b/data/recordings/20260515_093841_user.wav deleted file mode 100644 index 68a225e..0000000 Binary files a/data/recordings/20260515_093841_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093853_robot.wav b/data/recordings/20260515_093853_robot.wav deleted file mode 100644 index a2b8007..0000000 Binary files a/data/recordings/20260515_093853_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093853_user.wav b/data/recordings/20260515_093853_user.wav deleted file mode 100644 index 3e9b7f6..0000000 Binary files a/data/recordings/20260515_093853_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093856_user.wav b/data/recordings/20260515_093856_user.wav deleted file mode 100644 index 034500f..0000000 Binary files a/data/recordings/20260515_093856_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093858_robot.wav b/data/recordings/20260515_093858_robot.wav deleted file mode 100644 index 9b6a56b..0000000 Binary files a/data/recordings/20260515_093858_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093858_user.wav b/data/recordings/20260515_093858_user.wav deleted file mode 100644 index e557414..0000000 Binary files a/data/recordings/20260515_093858_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093901_robot.wav b/data/recordings/20260515_093901_robot.wav deleted file mode 100644 index 3c43431..0000000 Binary files a/data/recordings/20260515_093901_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093901_user.wav b/data/recordings/20260515_093901_user.wav deleted file mode 100644 index 1b5bd68..0000000 Binary files a/data/recordings/20260515_093901_user.wav and /dev/null differ diff --git a/data/recordings/20260515_093907_robot.wav b/data/recordings/20260515_093907_robot.wav deleted file mode 100644 index 7df80a2..0000000 Binary files a/data/recordings/20260515_093907_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_093907_user.wav b/data/recordings/20260515_093907_user.wav deleted file mode 100644 index eaef7a0..0000000 Binary files a/data/recordings/20260515_093907_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094006_robot.wav b/data/recordings/20260515_094006_robot.wav deleted file mode 100644 index 6a5bb60..0000000 Binary files a/data/recordings/20260515_094006_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094006_user.wav b/data/recordings/20260515_094006_user.wav deleted file mode 100644 index fd628f3..0000000 Binary files a/data/recordings/20260515_094006_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094013_robot.wav b/data/recordings/20260515_094013_robot.wav deleted file mode 100644 index da3be67..0000000 Binary files a/data/recordings/20260515_094013_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094013_user.wav b/data/recordings/20260515_094013_user.wav deleted file mode 100644 index 748f5dc..0000000 Binary files a/data/recordings/20260515_094013_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094024_robot.wav b/data/recordings/20260515_094024_robot.wav deleted file mode 100644 index 949158f..0000000 Binary files a/data/recordings/20260515_094024_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094024_user.wav b/data/recordings/20260515_094024_user.wav deleted file mode 100644 index 5c6c09f..0000000 Binary files a/data/recordings/20260515_094024_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094030_robot.wav b/data/recordings/20260515_094030_robot.wav deleted file mode 100644 index cbd4311..0000000 Binary files a/data/recordings/20260515_094030_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094030_user.wav b/data/recordings/20260515_094030_user.wav deleted file mode 100644 index 4549d0e..0000000 Binary files a/data/recordings/20260515_094030_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094033_user.wav b/data/recordings/20260515_094033_user.wav deleted file mode 100644 index 3eb6440..0000000 Binary files a/data/recordings/20260515_094033_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094034_user.wav b/data/recordings/20260515_094034_user.wav deleted file mode 100644 index 657b3f2..0000000 Binary files a/data/recordings/20260515_094034_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094035_user.wav b/data/recordings/20260515_094035_user.wav deleted file mode 100644 index 9a00160..0000000 Binary files a/data/recordings/20260515_094035_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094037_user.wav b/data/recordings/20260515_094037_user.wav deleted file mode 100644 index 6765f25..0000000 Binary files a/data/recordings/20260515_094037_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094041_robot.wav b/data/recordings/20260515_094041_robot.wav deleted file mode 100644 index a66232b..0000000 Binary files a/data/recordings/20260515_094041_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094041_user.wav b/data/recordings/20260515_094041_user.wav deleted file mode 100644 index 86ad36b..0000000 Binary files a/data/recordings/20260515_094041_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094048_user.wav b/data/recordings/20260515_094048_user.wav deleted file mode 100644 index e54aa91..0000000 Binary files a/data/recordings/20260515_094048_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094049_robot.wav b/data/recordings/20260515_094049_robot.wav deleted file mode 100644 index 1e6eec9..0000000 Binary files a/data/recordings/20260515_094049_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094049_user.wav b/data/recordings/20260515_094049_user.wav deleted file mode 100644 index 4d2b468..0000000 Binary files a/data/recordings/20260515_094049_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094058_user.wav b/data/recordings/20260515_094058_user.wav deleted file mode 100644 index 6014726..0000000 Binary files a/data/recordings/20260515_094058_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094059_user.wav b/data/recordings/20260515_094059_user.wav deleted file mode 100644 index 902a655..0000000 Binary files a/data/recordings/20260515_094059_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094120_robot.wav b/data/recordings/20260515_094120_robot.wav deleted file mode 100644 index 83f3990..0000000 Binary files a/data/recordings/20260515_094120_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094120_user.wav b/data/recordings/20260515_094120_user.wav deleted file mode 100644 index ae962d4..0000000 Binary files a/data/recordings/20260515_094120_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094127_robot.wav b/data/recordings/20260515_094127_robot.wav deleted file mode 100644 index 04d3b44..0000000 Binary files a/data/recordings/20260515_094127_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094127_user.wav b/data/recordings/20260515_094127_user.wav deleted file mode 100644 index 2c89df4..0000000 Binary files a/data/recordings/20260515_094127_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094132_user.wav b/data/recordings/20260515_094132_user.wav deleted file mode 100644 index 97a69b9..0000000 Binary files a/data/recordings/20260515_094132_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094134_robot.wav b/data/recordings/20260515_094134_robot.wav deleted file mode 100644 index 027e643..0000000 Binary files a/data/recordings/20260515_094134_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094134_user.wav b/data/recordings/20260515_094134_user.wav deleted file mode 100644 index 4c944d9..0000000 Binary files a/data/recordings/20260515_094134_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094153_robot.wav b/data/recordings/20260515_094153_robot.wav deleted file mode 100644 index cef1231..0000000 Binary files a/data/recordings/20260515_094153_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094153_user.wav b/data/recordings/20260515_094153_user.wav deleted file mode 100644 index 3b72525..0000000 Binary files a/data/recordings/20260515_094153_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094154_robot.wav b/data/recordings/20260515_094154_robot.wav deleted file mode 100644 index dfc2199..0000000 Binary files a/data/recordings/20260515_094154_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094154_user.wav b/data/recordings/20260515_094154_user.wav deleted file mode 100644 index 1345d0f..0000000 Binary files a/data/recordings/20260515_094154_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094159_user.wav b/data/recordings/20260515_094159_user.wav deleted file mode 100644 index 910c682..0000000 Binary files a/data/recordings/20260515_094159_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094209_robot.wav b/data/recordings/20260515_094209_robot.wav deleted file mode 100644 index be7276a..0000000 Binary files a/data/recordings/20260515_094209_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094209_user.wav b/data/recordings/20260515_094209_user.wav deleted file mode 100644 index 9c2bd9d..0000000 Binary files a/data/recordings/20260515_094209_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094216_user.wav b/data/recordings/20260515_094216_user.wav deleted file mode 100644 index 449049a..0000000 Binary files a/data/recordings/20260515_094216_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094230_user.wav b/data/recordings/20260515_094230_user.wav deleted file mode 100644 index d640429..0000000 Binary files a/data/recordings/20260515_094230_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094234_user.wav b/data/recordings/20260515_094234_user.wav deleted file mode 100644 index ab41365..0000000 Binary files a/data/recordings/20260515_094234_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094236_user.wav b/data/recordings/20260515_094236_user.wav deleted file mode 100644 index d23563d..0000000 Binary files a/data/recordings/20260515_094236_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094244_robot.wav b/data/recordings/20260515_094244_robot.wav deleted file mode 100644 index c24d0bf..0000000 Binary files a/data/recordings/20260515_094244_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094244_user.wav b/data/recordings/20260515_094244_user.wav deleted file mode 100644 index ba458cb..0000000 Binary files a/data/recordings/20260515_094244_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094256_user.wav b/data/recordings/20260515_094256_user.wav deleted file mode 100644 index aa16666..0000000 Binary files a/data/recordings/20260515_094256_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094324_user.wav b/data/recordings/20260515_094324_user.wav deleted file mode 100644 index 6d861ef..0000000 Binary files a/data/recordings/20260515_094324_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094402_user.wav b/data/recordings/20260515_094402_user.wav deleted file mode 100644 index 8cd479c..0000000 Binary files a/data/recordings/20260515_094402_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094416_robot.wav b/data/recordings/20260515_094416_robot.wav deleted file mode 100644 index a4f6603..0000000 Binary files a/data/recordings/20260515_094416_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094416_user.wav b/data/recordings/20260515_094416_user.wav deleted file mode 100644 index a276180..0000000 Binary files a/data/recordings/20260515_094416_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094421_robot.wav b/data/recordings/20260515_094421_robot.wav deleted file mode 100644 index 3e8ee7e..0000000 Binary files a/data/recordings/20260515_094421_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094421_user.wav b/data/recordings/20260515_094421_user.wav deleted file mode 100644 index 712d6cd..0000000 Binary files a/data/recordings/20260515_094421_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094423_user.wav b/data/recordings/20260515_094423_user.wav deleted file mode 100644 index fe8e857..0000000 Binary files a/data/recordings/20260515_094423_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094424_user.wav b/data/recordings/20260515_094424_user.wav deleted file mode 100644 index 68e95ca..0000000 Binary files a/data/recordings/20260515_094424_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094457_user.wav b/data/recordings/20260515_094457_user.wav deleted file mode 100644 index de06326..0000000 Binary files a/data/recordings/20260515_094457_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094504_robot.wav b/data/recordings/20260515_094504_robot.wav deleted file mode 100644 index 5d3e048..0000000 Binary files a/data/recordings/20260515_094504_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094504_user.wav b/data/recordings/20260515_094504_user.wav deleted file mode 100644 index 8b186ae..0000000 Binary files a/data/recordings/20260515_094504_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094526_robot.wav b/data/recordings/20260515_094526_robot.wav deleted file mode 100644 index de9e13a..0000000 Binary files a/data/recordings/20260515_094526_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094526_user.wav b/data/recordings/20260515_094526_user.wav deleted file mode 100644 index 733a5d7..0000000 Binary files a/data/recordings/20260515_094526_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094529_robot.wav b/data/recordings/20260515_094529_robot.wav deleted file mode 100644 index d8dc483..0000000 Binary files a/data/recordings/20260515_094529_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094529_user.wav b/data/recordings/20260515_094529_user.wav deleted file mode 100644 index eb3d0b8..0000000 Binary files a/data/recordings/20260515_094529_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094532_user.wav b/data/recordings/20260515_094532_user.wav deleted file mode 100644 index 3213de3..0000000 Binary files a/data/recordings/20260515_094532_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094616_robot.wav b/data/recordings/20260515_094616_robot.wav deleted file mode 100644 index 122dc76..0000000 Binary files a/data/recordings/20260515_094616_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094616_user.wav b/data/recordings/20260515_094616_user.wav deleted file mode 100644 index 073a1b7..0000000 Binary files a/data/recordings/20260515_094616_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094627_user.wav b/data/recordings/20260515_094627_user.wav deleted file mode 100644 index 47ec685..0000000 Binary files a/data/recordings/20260515_094627_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094637_robot.wav b/data/recordings/20260515_094637_robot.wav deleted file mode 100644 index a0d234d..0000000 Binary files a/data/recordings/20260515_094637_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094637_user.wav b/data/recordings/20260515_094637_user.wav deleted file mode 100644 index 45497fc..0000000 Binary files a/data/recordings/20260515_094637_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094647_user.wav b/data/recordings/20260515_094647_user.wav deleted file mode 100644 index fc2cf3a..0000000 Binary files a/data/recordings/20260515_094647_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094650_user.wav b/data/recordings/20260515_094650_user.wav deleted file mode 100644 index b5e2e46..0000000 Binary files a/data/recordings/20260515_094650_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094713_user.wav b/data/recordings/20260515_094713_user.wav deleted file mode 100644 index 3d06f01..0000000 Binary files a/data/recordings/20260515_094713_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094714_robot.wav b/data/recordings/20260515_094714_robot.wav deleted file mode 100644 index 3c8b743..0000000 Binary files a/data/recordings/20260515_094714_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094714_user.wav b/data/recordings/20260515_094714_user.wav deleted file mode 100644 index fee24a1..0000000 Binary files a/data/recordings/20260515_094714_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094724_user.wav b/data/recordings/20260515_094724_user.wav deleted file mode 100644 index 49f4eb6..0000000 Binary files a/data/recordings/20260515_094724_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094725_robot.wav b/data/recordings/20260515_094725_robot.wav deleted file mode 100644 index ead095e..0000000 Binary files a/data/recordings/20260515_094725_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094725_user.wav b/data/recordings/20260515_094725_user.wav deleted file mode 100644 index 907ad8c..0000000 Binary files a/data/recordings/20260515_094725_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094738_user.wav b/data/recordings/20260515_094738_user.wav deleted file mode 100644 index f09fb00..0000000 Binary files a/data/recordings/20260515_094738_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094739_user.wav b/data/recordings/20260515_094739_user.wav deleted file mode 100644 index decb3b5..0000000 Binary files a/data/recordings/20260515_094739_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094751_robot.wav b/data/recordings/20260515_094751_robot.wav deleted file mode 100644 index c0fa2bd..0000000 Binary files a/data/recordings/20260515_094751_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094751_user.wav b/data/recordings/20260515_094751_user.wav deleted file mode 100644 index e3c8f67..0000000 Binary files a/data/recordings/20260515_094751_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094755_robot.wav b/data/recordings/20260515_094755_robot.wav deleted file mode 100644 index 3289488..0000000 Binary files a/data/recordings/20260515_094755_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094755_user.wav b/data/recordings/20260515_094755_user.wav deleted file mode 100644 index e4bad1a..0000000 Binary files a/data/recordings/20260515_094755_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094757_user.wav b/data/recordings/20260515_094757_user.wav deleted file mode 100644 index f727ad3..0000000 Binary files a/data/recordings/20260515_094757_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094758_user.wav b/data/recordings/20260515_094758_user.wav deleted file mode 100644 index 28f21f3..0000000 Binary files a/data/recordings/20260515_094758_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094831_user.wav b/data/recordings/20260515_094831_user.wav deleted file mode 100644 index bc44e95..0000000 Binary files a/data/recordings/20260515_094831_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094834_user.wav b/data/recordings/20260515_094834_user.wav deleted file mode 100644 index fa08f15..0000000 Binary files a/data/recordings/20260515_094834_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094839_robot.wav b/data/recordings/20260515_094839_robot.wav deleted file mode 100644 index af3e61a..0000000 Binary files a/data/recordings/20260515_094839_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094839_user.wav b/data/recordings/20260515_094839_user.wav deleted file mode 100644 index 959a7d5..0000000 Binary files a/data/recordings/20260515_094839_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094843_robot.wav b/data/recordings/20260515_094843_robot.wav deleted file mode 100644 index 7c95e60..0000000 Binary files a/data/recordings/20260515_094843_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094843_user.wav b/data/recordings/20260515_094843_user.wav deleted file mode 100644 index 69169e3..0000000 Binary files a/data/recordings/20260515_094843_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094844_user.wav b/data/recordings/20260515_094844_user.wav deleted file mode 100644 index bbcedd6..0000000 Binary files a/data/recordings/20260515_094844_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094848_user.wav b/data/recordings/20260515_094848_user.wav deleted file mode 100644 index 8694783..0000000 Binary files a/data/recordings/20260515_094848_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094855_user.wav b/data/recordings/20260515_094855_user.wav deleted file mode 100644 index a759608..0000000 Binary files a/data/recordings/20260515_094855_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094856_user.wav b/data/recordings/20260515_094856_user.wav deleted file mode 100644 index 76a7a48..0000000 Binary files a/data/recordings/20260515_094856_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094900_user.wav b/data/recordings/20260515_094900_user.wav deleted file mode 100644 index 8e7eb0c..0000000 Binary files a/data/recordings/20260515_094900_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094907_user.wav b/data/recordings/20260515_094907_user.wav deleted file mode 100644 index 29db47a..0000000 Binary files a/data/recordings/20260515_094907_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094913_user.wav b/data/recordings/20260515_094913_user.wav deleted file mode 100644 index 9d0874e..0000000 Binary files a/data/recordings/20260515_094913_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094923_robot.wav b/data/recordings/20260515_094923_robot.wav deleted file mode 100644 index cd30f1b..0000000 Binary files a/data/recordings/20260515_094923_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094923_user.wav b/data/recordings/20260515_094923_user.wav deleted file mode 100644 index 9494b38..0000000 Binary files a/data/recordings/20260515_094923_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094934_robot.wav b/data/recordings/20260515_094934_robot.wav deleted file mode 100644 index 5cf02fd..0000000 Binary files a/data/recordings/20260515_094934_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094934_user.wav b/data/recordings/20260515_094934_user.wav deleted file mode 100644 index 9ecee0b..0000000 Binary files a/data/recordings/20260515_094934_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094935_user.wav b/data/recordings/20260515_094935_user.wav deleted file mode 100644 index b270982..0000000 Binary files a/data/recordings/20260515_094935_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094938_robot.wav b/data/recordings/20260515_094938_robot.wav deleted file mode 100644 index a2d50f2..0000000 Binary files a/data/recordings/20260515_094938_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094938_user.wav b/data/recordings/20260515_094938_user.wav deleted file mode 100644 index 7cbc967..0000000 Binary files a/data/recordings/20260515_094938_user.wav and /dev/null differ diff --git a/data/recordings/20260515_094955_robot.wav b/data/recordings/20260515_094955_robot.wav deleted file mode 100644 index c7addc6..0000000 Binary files a/data/recordings/20260515_094955_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_094955_user.wav b/data/recordings/20260515_094955_user.wav deleted file mode 100644 index 5d502f6..0000000 Binary files a/data/recordings/20260515_094955_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095004_robot.wav b/data/recordings/20260515_095004_robot.wav deleted file mode 100644 index 4e76d38..0000000 Binary files a/data/recordings/20260515_095004_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_095004_user.wav b/data/recordings/20260515_095004_user.wav deleted file mode 100644 index 3729fd1..0000000 Binary files a/data/recordings/20260515_095004_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095005_user.wav b/data/recordings/20260515_095005_user.wav deleted file mode 100644 index a4cb9aa..0000000 Binary files a/data/recordings/20260515_095005_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095006_user.wav b/data/recordings/20260515_095006_user.wav deleted file mode 100644 index a51c093..0000000 Binary files a/data/recordings/20260515_095006_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095018_user.wav b/data/recordings/20260515_095018_user.wav deleted file mode 100644 index fa17db8..0000000 Binary files a/data/recordings/20260515_095018_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095020_robot.wav b/data/recordings/20260515_095020_robot.wav deleted file mode 100644 index 102850a..0000000 Binary files a/data/recordings/20260515_095020_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_095020_user.wav b/data/recordings/20260515_095020_user.wav deleted file mode 100644 index 03635a2..0000000 Binary files a/data/recordings/20260515_095020_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095028_robot.wav b/data/recordings/20260515_095028_robot.wav deleted file mode 100644 index 565f52a..0000000 Binary files a/data/recordings/20260515_095028_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_095028_user.wav b/data/recordings/20260515_095028_user.wav deleted file mode 100644 index 791eb4f..0000000 Binary files a/data/recordings/20260515_095028_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095029_user.wav b/data/recordings/20260515_095029_user.wav deleted file mode 100644 index b0c4c1f..0000000 Binary files a/data/recordings/20260515_095029_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095031_user.wav b/data/recordings/20260515_095031_user.wav deleted file mode 100644 index 48bbbac..0000000 Binary files a/data/recordings/20260515_095031_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095042_robot.wav b/data/recordings/20260515_095042_robot.wav deleted file mode 100644 index 4d960a6..0000000 Binary files a/data/recordings/20260515_095042_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_095042_user.wav b/data/recordings/20260515_095042_user.wav deleted file mode 100644 index 2075b69..0000000 Binary files a/data/recordings/20260515_095042_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095047_robot.wav b/data/recordings/20260515_095047_robot.wav deleted file mode 100644 index e82151a..0000000 Binary files a/data/recordings/20260515_095047_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_095047_user.wav b/data/recordings/20260515_095047_user.wav deleted file mode 100644 index 7c1a192..0000000 Binary files a/data/recordings/20260515_095047_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095050_robot.wav b/data/recordings/20260515_095050_robot.wav deleted file mode 100644 index fe5d006..0000000 Binary files a/data/recordings/20260515_095050_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_095050_user.wav b/data/recordings/20260515_095050_user.wav deleted file mode 100644 index 1604bec..0000000 Binary files a/data/recordings/20260515_095050_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095107_robot.wav b/data/recordings/20260515_095107_robot.wav deleted file mode 100644 index f91ba83..0000000 Binary files a/data/recordings/20260515_095107_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_095107_user.wav b/data/recordings/20260515_095107_user.wav deleted file mode 100644 index 7dea71f..0000000 Binary files a/data/recordings/20260515_095107_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095108_robot.wav b/data/recordings/20260515_095108_robot.wav deleted file mode 100644 index 788654d..0000000 Binary files a/data/recordings/20260515_095108_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_095108_user.wav b/data/recordings/20260515_095108_user.wav deleted file mode 100644 index 263ad0e..0000000 Binary files a/data/recordings/20260515_095108_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095112_robot.wav b/data/recordings/20260515_095112_robot.wav deleted file mode 100644 index 7f9a2d2..0000000 Binary files a/data/recordings/20260515_095112_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_095112_user.wav b/data/recordings/20260515_095112_user.wav deleted file mode 100644 index e95f89e..0000000 Binary files a/data/recordings/20260515_095112_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095121_robot.wav b/data/recordings/20260515_095121_robot.wav deleted file mode 100644 index 7cde510..0000000 Binary files a/data/recordings/20260515_095121_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_095121_user.wav b/data/recordings/20260515_095121_user.wav deleted file mode 100644 index c728b26..0000000 Binary files a/data/recordings/20260515_095121_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095123_user.wav b/data/recordings/20260515_095123_user.wav deleted file mode 100644 index c3b7984..0000000 Binary files a/data/recordings/20260515_095123_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095124_user.wav b/data/recordings/20260515_095124_user.wav deleted file mode 100644 index bff07dc..0000000 Binary files a/data/recordings/20260515_095124_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095135_robot.wav b/data/recordings/20260515_095135_robot.wav deleted file mode 100644 index 8bc99eb..0000000 Binary files a/data/recordings/20260515_095135_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_095135_user.wav b/data/recordings/20260515_095135_user.wav deleted file mode 100644 index eb64f4b..0000000 Binary files a/data/recordings/20260515_095135_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095148_user.wav b/data/recordings/20260515_095148_user.wav deleted file mode 100644 index e0b9577..0000000 Binary files a/data/recordings/20260515_095148_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095150_user.wav b/data/recordings/20260515_095150_user.wav deleted file mode 100644 index 49e46ef..0000000 Binary files a/data/recordings/20260515_095150_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095217_user.wav b/data/recordings/20260515_095217_user.wav deleted file mode 100644 index 42d19b5..0000000 Binary files a/data/recordings/20260515_095217_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095223_user.wav b/data/recordings/20260515_095223_user.wav deleted file mode 100644 index acd3027..0000000 Binary files a/data/recordings/20260515_095223_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095227_user.wav b/data/recordings/20260515_095227_user.wav deleted file mode 100644 index 81cbf60..0000000 Binary files a/data/recordings/20260515_095227_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095228_robot.wav b/data/recordings/20260515_095228_robot.wav deleted file mode 100644 index a182a64..0000000 Binary files a/data/recordings/20260515_095228_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_095228_user.wav b/data/recordings/20260515_095228_user.wav deleted file mode 100644 index 59c601d..0000000 Binary files a/data/recordings/20260515_095228_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095240_robot.wav b/data/recordings/20260515_095240_robot.wav deleted file mode 100644 index 6ffa9db..0000000 Binary files a/data/recordings/20260515_095240_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_095240_user.wav b/data/recordings/20260515_095240_user.wav deleted file mode 100644 index 65a1fe2..0000000 Binary files a/data/recordings/20260515_095240_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095245_robot.wav b/data/recordings/20260515_095245_robot.wav deleted file mode 100644 index da44be3..0000000 Binary files a/data/recordings/20260515_095245_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_095245_user.wav b/data/recordings/20260515_095245_user.wav deleted file mode 100644 index b5693d3..0000000 Binary files a/data/recordings/20260515_095245_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095246_user.wav b/data/recordings/20260515_095246_user.wav deleted file mode 100644 index 06353a7..0000000 Binary files a/data/recordings/20260515_095246_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095250_user.wav b/data/recordings/20260515_095250_user.wav deleted file mode 100644 index 8224e4d..0000000 Binary files a/data/recordings/20260515_095250_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095252_robot.wav b/data/recordings/20260515_095252_robot.wav deleted file mode 100644 index 9ebf39f..0000000 Binary files a/data/recordings/20260515_095252_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_095252_user.wav b/data/recordings/20260515_095252_user.wav deleted file mode 100644 index 1658cce..0000000 Binary files a/data/recordings/20260515_095252_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095303_user.wav b/data/recordings/20260515_095303_user.wav deleted file mode 100644 index 1b7ba34..0000000 Binary files a/data/recordings/20260515_095303_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095311_user.wav b/data/recordings/20260515_095311_user.wav deleted file mode 100644 index 15168cb..0000000 Binary files a/data/recordings/20260515_095311_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095312_user.wav b/data/recordings/20260515_095312_user.wav deleted file mode 100644 index 88d17c7..0000000 Binary files a/data/recordings/20260515_095312_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095319_user.wav b/data/recordings/20260515_095319_user.wav deleted file mode 100644 index 51d6f25..0000000 Binary files a/data/recordings/20260515_095319_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095323_user.wav b/data/recordings/20260515_095323_user.wav deleted file mode 100644 index b347d1d..0000000 Binary files a/data/recordings/20260515_095323_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095327_user.wav b/data/recordings/20260515_095327_user.wav deleted file mode 100644 index 62c5616..0000000 Binary files a/data/recordings/20260515_095327_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095329_user.wav b/data/recordings/20260515_095329_user.wav deleted file mode 100644 index 6c600ef..0000000 Binary files a/data/recordings/20260515_095329_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095331_user.wav b/data/recordings/20260515_095331_user.wav deleted file mode 100644 index 6992897..0000000 Binary files a/data/recordings/20260515_095331_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095335_user.wav b/data/recordings/20260515_095335_user.wav deleted file mode 100644 index 4332e00..0000000 Binary files a/data/recordings/20260515_095335_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095338_user.wav b/data/recordings/20260515_095338_user.wav deleted file mode 100644 index 660137b..0000000 Binary files a/data/recordings/20260515_095338_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095348_user.wav b/data/recordings/20260515_095348_user.wav deleted file mode 100644 index f5fb732..0000000 Binary files a/data/recordings/20260515_095348_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095349_user.wav b/data/recordings/20260515_095349_user.wav deleted file mode 100644 index 345d94b..0000000 Binary files a/data/recordings/20260515_095349_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095355_robot.wav b/data/recordings/20260515_095355_robot.wav deleted file mode 100644 index 68591e8..0000000 Binary files a/data/recordings/20260515_095355_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_095355_user.wav b/data/recordings/20260515_095355_user.wav deleted file mode 100644 index 1a9305e..0000000 Binary files a/data/recordings/20260515_095355_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095358_robot.wav b/data/recordings/20260515_095358_robot.wav deleted file mode 100644 index c38b3d6..0000000 Binary files a/data/recordings/20260515_095358_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_095358_user.wav b/data/recordings/20260515_095358_user.wav deleted file mode 100644 index 0c4d27e..0000000 Binary files a/data/recordings/20260515_095358_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095404_user.wav b/data/recordings/20260515_095404_user.wav deleted file mode 100644 index 5ae5882..0000000 Binary files a/data/recordings/20260515_095404_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095429_user.wav b/data/recordings/20260515_095429_user.wav deleted file mode 100644 index aa949b5..0000000 Binary files a/data/recordings/20260515_095429_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095452_robot.wav b/data/recordings/20260515_095452_robot.wav deleted file mode 100644 index 2cab77a..0000000 Binary files a/data/recordings/20260515_095452_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_095452_user.wav b/data/recordings/20260515_095452_user.wav deleted file mode 100644 index 6278c52..0000000 Binary files a/data/recordings/20260515_095452_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095504_robot.wav b/data/recordings/20260515_095504_robot.wav deleted file mode 100644 index 943fe3a..0000000 Binary files a/data/recordings/20260515_095504_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_095504_user.wav b/data/recordings/20260515_095504_user.wav deleted file mode 100644 index e150ddb..0000000 Binary files a/data/recordings/20260515_095504_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095516_robot.wav b/data/recordings/20260515_095516_robot.wav deleted file mode 100644 index 35ef363..0000000 Binary files a/data/recordings/20260515_095516_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_095516_user.wav b/data/recordings/20260515_095516_user.wav deleted file mode 100644 index 2ac5cc0..0000000 Binary files a/data/recordings/20260515_095516_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095522_robot.wav b/data/recordings/20260515_095522_robot.wav deleted file mode 100644 index fe21b15..0000000 Binary files a/data/recordings/20260515_095522_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_095522_user.wav b/data/recordings/20260515_095522_user.wav deleted file mode 100644 index 66a2fb8..0000000 Binary files a/data/recordings/20260515_095522_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095524_robot.wav b/data/recordings/20260515_095524_robot.wav deleted file mode 100644 index 86f0df4..0000000 Binary files a/data/recordings/20260515_095524_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_095524_user.wav b/data/recordings/20260515_095524_user.wav deleted file mode 100644 index bf7d3c6..0000000 Binary files a/data/recordings/20260515_095524_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095528_user.wav b/data/recordings/20260515_095528_user.wav deleted file mode 100644 index c18ec97..0000000 Binary files a/data/recordings/20260515_095528_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095530_user.wav b/data/recordings/20260515_095530_user.wav deleted file mode 100644 index abd0fb5..0000000 Binary files a/data/recordings/20260515_095530_user.wav and /dev/null differ diff --git a/data/recordings/20260515_095750_user.wav b/data/recordings/20260515_095750_user.wav deleted file mode 100644 index fee9882..0000000 Binary files a/data/recordings/20260515_095750_user.wav and /dev/null differ diff --git a/data/recordings/20260515_100406_user.wav b/data/recordings/20260515_100406_user.wav deleted file mode 100644 index f7d7fa9..0000000 Binary files a/data/recordings/20260515_100406_user.wav and /dev/null differ diff --git a/data/recordings/20260515_100407_user.wav b/data/recordings/20260515_100407_user.wav deleted file mode 100644 index cd33f72..0000000 Binary files a/data/recordings/20260515_100407_user.wav and /dev/null differ diff --git a/data/recordings/20260515_100408_user.wav b/data/recordings/20260515_100408_user.wav deleted file mode 100644 index 3fc351c..0000000 Binary files a/data/recordings/20260515_100408_user.wav and /dev/null differ diff --git a/data/recordings/20260515_102425_robot.wav b/data/recordings/20260515_102425_robot.wav deleted file mode 100644 index 6adfe9c..0000000 Binary files a/data/recordings/20260515_102425_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_102425_user.wav b/data/recordings/20260515_102425_user.wav deleted file mode 100644 index 4945b1c..0000000 Binary files a/data/recordings/20260515_102425_user.wav and /dev/null differ diff --git a/data/recordings/20260515_102430_robot.wav b/data/recordings/20260515_102430_robot.wav deleted file mode 100644 index bf9b927..0000000 Binary files a/data/recordings/20260515_102430_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_102430_user.wav b/data/recordings/20260515_102430_user.wav deleted file mode 100644 index b2d22f6..0000000 Binary files a/data/recordings/20260515_102430_user.wav and /dev/null differ diff --git a/data/recordings/20260515_102431_robot.wav b/data/recordings/20260515_102431_robot.wav deleted file mode 100644 index 4dbd57d..0000000 Binary files a/data/recordings/20260515_102431_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_102431_user.wav b/data/recordings/20260515_102431_user.wav deleted file mode 100644 index 11d7fb5..0000000 Binary files a/data/recordings/20260515_102431_user.wav and /dev/null differ diff --git a/data/recordings/20260515_102435_user.wav b/data/recordings/20260515_102435_user.wav deleted file mode 100644 index 2d50c53..0000000 Binary files a/data/recordings/20260515_102435_user.wav and /dev/null differ diff --git a/data/recordings/20260515_102438_user.wav b/data/recordings/20260515_102438_user.wav deleted file mode 100644 index 8396226..0000000 Binary files a/data/recordings/20260515_102438_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105540_robot.wav b/data/recordings/20260515_105540_robot.wav deleted file mode 100644 index b4db206..0000000 Binary files a/data/recordings/20260515_105540_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_105543_user.wav b/data/recordings/20260515_105543_user.wav deleted file mode 100644 index 860ba62..0000000 Binary files a/data/recordings/20260515_105543_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105544_robot.wav b/data/recordings/20260515_105544_robot.wav deleted file mode 100644 index f3531b6..0000000 Binary files a/data/recordings/20260515_105544_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_105544_user.wav b/data/recordings/20260515_105544_user.wav deleted file mode 100644 index f56b2e7..0000000 Binary files a/data/recordings/20260515_105544_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105548_robot.wav b/data/recordings/20260515_105548_robot.wav deleted file mode 100644 index 9446c48..0000000 Binary files a/data/recordings/20260515_105548_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_105548_user.wav b/data/recordings/20260515_105548_user.wav deleted file mode 100644 index 690ad34..0000000 Binary files a/data/recordings/20260515_105548_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105553_user.wav b/data/recordings/20260515_105553_user.wav deleted file mode 100644 index 61f8967..0000000 Binary files a/data/recordings/20260515_105553_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105559_user.wav b/data/recordings/20260515_105559_user.wav deleted file mode 100644 index c3639c0..0000000 Binary files a/data/recordings/20260515_105559_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105602_user.wav b/data/recordings/20260515_105602_user.wav deleted file mode 100644 index d1ddb43..0000000 Binary files a/data/recordings/20260515_105602_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105606_user.wav b/data/recordings/20260515_105606_user.wav deleted file mode 100644 index dad83b7..0000000 Binary files a/data/recordings/20260515_105606_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105610_robot.wav b/data/recordings/20260515_105610_robot.wav deleted file mode 100644 index 03808df..0000000 Binary files a/data/recordings/20260515_105610_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_105610_user.wav b/data/recordings/20260515_105610_user.wav deleted file mode 100644 index 7bcd3f4..0000000 Binary files a/data/recordings/20260515_105610_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105615_robot.wav b/data/recordings/20260515_105615_robot.wav deleted file mode 100644 index 7d6b879..0000000 Binary files a/data/recordings/20260515_105615_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_105615_user.wav b/data/recordings/20260515_105615_user.wav deleted file mode 100644 index 1c86754..0000000 Binary files a/data/recordings/20260515_105615_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105622_user.wav b/data/recordings/20260515_105622_user.wav deleted file mode 100644 index fa29b07..0000000 Binary files a/data/recordings/20260515_105622_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105635_user.wav b/data/recordings/20260515_105635_user.wav deleted file mode 100644 index 97d4908..0000000 Binary files a/data/recordings/20260515_105635_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105637_user.wav b/data/recordings/20260515_105637_user.wav deleted file mode 100644 index 20072ef..0000000 Binary files a/data/recordings/20260515_105637_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105643_user.wav b/data/recordings/20260515_105643_user.wav deleted file mode 100644 index 0e3c7a5..0000000 Binary files a/data/recordings/20260515_105643_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105647_user.wav b/data/recordings/20260515_105647_user.wav deleted file mode 100644 index d14d55f..0000000 Binary files a/data/recordings/20260515_105647_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105652_user.wav b/data/recordings/20260515_105652_user.wav deleted file mode 100644 index 7588f87..0000000 Binary files a/data/recordings/20260515_105652_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105655_robot.wav b/data/recordings/20260515_105655_robot.wav deleted file mode 100644 index 81586d2..0000000 Binary files a/data/recordings/20260515_105655_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_105655_user.wav b/data/recordings/20260515_105655_user.wav deleted file mode 100644 index 54585a5..0000000 Binary files a/data/recordings/20260515_105655_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105700_robot.wav b/data/recordings/20260515_105700_robot.wav deleted file mode 100644 index 1d7d439..0000000 Binary files a/data/recordings/20260515_105700_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_105700_user.wav b/data/recordings/20260515_105700_user.wav deleted file mode 100644 index ea3b991..0000000 Binary files a/data/recordings/20260515_105700_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105701_user.wav b/data/recordings/20260515_105701_user.wav deleted file mode 100644 index ebf3e04..0000000 Binary files a/data/recordings/20260515_105701_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105702_robot.wav b/data/recordings/20260515_105702_robot.wav deleted file mode 100644 index 7af4317..0000000 Binary files a/data/recordings/20260515_105702_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_105702_user.wav b/data/recordings/20260515_105702_user.wav deleted file mode 100644 index 0f3fd8d..0000000 Binary files a/data/recordings/20260515_105702_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105709_robot.wav b/data/recordings/20260515_105709_robot.wav deleted file mode 100644 index c476613..0000000 Binary files a/data/recordings/20260515_105709_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_105709_user.wav b/data/recordings/20260515_105709_user.wav deleted file mode 100644 index 4239325..0000000 Binary files a/data/recordings/20260515_105709_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105746_user.wav b/data/recordings/20260515_105746_user.wav deleted file mode 100644 index f56b872..0000000 Binary files a/data/recordings/20260515_105746_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105747_robot.wav b/data/recordings/20260515_105747_robot.wav deleted file mode 100644 index fff72f8..0000000 Binary files a/data/recordings/20260515_105747_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_105747_user.wav b/data/recordings/20260515_105747_user.wav deleted file mode 100644 index eada8fd..0000000 Binary files a/data/recordings/20260515_105747_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105756_robot.wav b/data/recordings/20260515_105756_robot.wav deleted file mode 100644 index 72c8f0b..0000000 Binary files a/data/recordings/20260515_105756_robot.wav and /dev/null differ diff --git a/data/recordings/20260515_105756_user.wav b/data/recordings/20260515_105756_user.wav deleted file mode 100644 index 889e02c..0000000 Binary files a/data/recordings/20260515_105756_user.wav and /dev/null differ diff --git a/data/recordings/20260515_105802_user.wav b/data/recordings/20260515_105802_user.wav deleted file mode 100644 index b676a2e..0000000 Binary files a/data/recordings/20260515_105802_user.wav and /dev/null differ diff --git a/data/recordings/20260516_112009_robot.wav b/data/recordings/20260516_112009_robot.wav deleted file mode 100644 index a2f122b..0000000 Binary files a/data/recordings/20260516_112009_robot.wav and /dev/null differ diff --git a/data/recordings/20260516_112010_user.wav b/data/recordings/20260516_112010_user.wav deleted file mode 100644 index fe54df4..0000000 Binary files a/data/recordings/20260516_112010_user.wav and /dev/null differ diff --git a/data/recordings/20260519_082138_robot.wav b/data/recordings/20260519_082138_robot.wav deleted file mode 100644 index b132fb1..0000000 Binary files a/data/recordings/20260519_082138_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_082138_user.wav b/data/recordings/20260519_082138_user.wav deleted file mode 100644 index 0d47512..0000000 Binary files a/data/recordings/20260519_082138_user.wav and /dev/null differ diff --git a/data/recordings/20260519_082148_user.wav b/data/recordings/20260519_082148_user.wav deleted file mode 100644 index f24f0c6..0000000 Binary files a/data/recordings/20260519_082148_user.wav and /dev/null differ diff --git a/data/recordings/20260519_082158_robot.wav b/data/recordings/20260519_082158_robot.wav deleted file mode 100644 index 7bdc254..0000000 Binary files a/data/recordings/20260519_082158_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_082158_user.wav b/data/recordings/20260519_082158_user.wav deleted file mode 100644 index 39079b6..0000000 Binary files a/data/recordings/20260519_082158_user.wav and /dev/null differ diff --git a/data/recordings/20260519_084918_robot.wav b/data/recordings/20260519_084918_robot.wav deleted file mode 100644 index 6d0d569..0000000 Binary files a/data/recordings/20260519_084918_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_084918_user.wav b/data/recordings/20260519_084918_user.wav deleted file mode 100644 index d1f5510..0000000 Binary files a/data/recordings/20260519_084918_user.wav and /dev/null differ diff --git a/data/recordings/20260519_084923_robot.wav b/data/recordings/20260519_084923_robot.wav deleted file mode 100644 index eaa7e3f..0000000 Binary files a/data/recordings/20260519_084923_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_084923_user.wav b/data/recordings/20260519_084923_user.wav deleted file mode 100644 index 913c6bb..0000000 Binary files a/data/recordings/20260519_084923_user.wav and /dev/null differ diff --git a/data/recordings/20260519_084927_user.wav b/data/recordings/20260519_084927_user.wav deleted file mode 100644 index 8f37aad..0000000 Binary files a/data/recordings/20260519_084927_user.wav and /dev/null differ diff --git a/data/recordings/20260519_115018_robot.wav b/data/recordings/20260519_115018_robot.wav deleted file mode 100644 index ebfb98d..0000000 Binary files a/data/recordings/20260519_115018_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_115018_user.wav b/data/recordings/20260519_115018_user.wav deleted file mode 100644 index 4fb4567..0000000 Binary files a/data/recordings/20260519_115018_user.wav and /dev/null differ diff --git a/data/recordings/20260519_115025_robot.wav b/data/recordings/20260519_115025_robot.wav deleted file mode 100644 index 73ef4d3..0000000 Binary files a/data/recordings/20260519_115025_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_115025_user.wav b/data/recordings/20260519_115025_user.wav deleted file mode 100644 index 0b285c8..0000000 Binary files a/data/recordings/20260519_115025_user.wav and /dev/null differ diff --git a/data/recordings/20260519_115036_robot.wav b/data/recordings/20260519_115036_robot.wav deleted file mode 100644 index 804fe3a..0000000 Binary files a/data/recordings/20260519_115036_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_115036_user.wav b/data/recordings/20260519_115036_user.wav deleted file mode 100644 index 7f70b67..0000000 Binary files a/data/recordings/20260519_115036_user.wav and /dev/null differ diff --git a/data/recordings/20260519_115039_user.wav b/data/recordings/20260519_115039_user.wav deleted file mode 100644 index 18113f5..0000000 Binary files a/data/recordings/20260519_115039_user.wav and /dev/null differ diff --git a/data/recordings/20260519_115041_user.wav b/data/recordings/20260519_115041_user.wav deleted file mode 100644 index 4c70f8d..0000000 Binary files a/data/recordings/20260519_115041_user.wav and /dev/null differ diff --git a/data/recordings/20260519_115049_robot.wav b/data/recordings/20260519_115049_robot.wav deleted file mode 100644 index d0bf7af..0000000 Binary files a/data/recordings/20260519_115049_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_115049_user.wav b/data/recordings/20260519_115049_user.wav deleted file mode 100644 index 8396788..0000000 Binary files a/data/recordings/20260519_115049_user.wav and /dev/null differ diff --git a/data/recordings/20260519_115102_robot.wav b/data/recordings/20260519_115102_robot.wav deleted file mode 100644 index 0314714..0000000 Binary files a/data/recordings/20260519_115102_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_115109_robot.wav b/data/recordings/20260519_115109_robot.wav deleted file mode 100644 index 8b12197..0000000 Binary files a/data/recordings/20260519_115109_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_115109_user.wav b/data/recordings/20260519_115109_user.wav deleted file mode 100644 index a9858ba..0000000 Binary files a/data/recordings/20260519_115109_user.wav and /dev/null differ diff --git a/data/recordings/20260519_115113_robot.wav b/data/recordings/20260519_115113_robot.wav deleted file mode 100644 index 17e73fa..0000000 Binary files a/data/recordings/20260519_115113_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_115125_robot.wav b/data/recordings/20260519_115125_robot.wav deleted file mode 100644 index dc3d783..0000000 Binary files a/data/recordings/20260519_115125_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_115125_user.wav b/data/recordings/20260519_115125_user.wav deleted file mode 100644 index 90b32b6..0000000 Binary files a/data/recordings/20260519_115125_user.wav and /dev/null differ diff --git a/data/recordings/20260519_115131_robot.wav b/data/recordings/20260519_115131_robot.wav deleted file mode 100644 index 57f52e8..0000000 Binary files a/data/recordings/20260519_115131_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_115131_user.wav b/data/recordings/20260519_115131_user.wav deleted file mode 100644 index 82f7bc2..0000000 Binary files a/data/recordings/20260519_115131_user.wav and /dev/null differ diff --git a/data/recordings/20260519_115138_user.wav b/data/recordings/20260519_115138_user.wav deleted file mode 100644 index a139650..0000000 Binary files a/data/recordings/20260519_115138_user.wav and /dev/null differ diff --git a/data/recordings/20260519_130234_robot.wav b/data/recordings/20260519_130234_robot.wav deleted file mode 100644 index accead2..0000000 Binary files a/data/recordings/20260519_130234_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_130239_user.wav b/data/recordings/20260519_130239_user.wav deleted file mode 100644 index 513a018..0000000 Binary files a/data/recordings/20260519_130239_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154619_user.wav b/data/recordings/20260519_154619_user.wav deleted file mode 100644 index 10d67ea..0000000 Binary files a/data/recordings/20260519_154619_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154620_user.wav b/data/recordings/20260519_154620_user.wav deleted file mode 100644 index f844f24..0000000 Binary files a/data/recordings/20260519_154620_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154621_robot.wav b/data/recordings/20260519_154621_robot.wav deleted file mode 100644 index c8a8b3e..0000000 Binary files a/data/recordings/20260519_154621_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_154621_user.wav b/data/recordings/20260519_154621_user.wav deleted file mode 100644 index ab6c04d..0000000 Binary files a/data/recordings/20260519_154621_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154632_robot.wav b/data/recordings/20260519_154632_robot.wav deleted file mode 100644 index 832f8fd..0000000 Binary files a/data/recordings/20260519_154632_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_154632_user.wav b/data/recordings/20260519_154632_user.wav deleted file mode 100644 index b8b4835..0000000 Binary files a/data/recordings/20260519_154632_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154633_robot.wav b/data/recordings/20260519_154633_robot.wav deleted file mode 100644 index f935826..0000000 Binary files a/data/recordings/20260519_154633_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_154633_user.wav b/data/recordings/20260519_154633_user.wav deleted file mode 100644 index db33ab1..0000000 Binary files a/data/recordings/20260519_154633_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154636_robot.wav b/data/recordings/20260519_154636_robot.wav deleted file mode 100644 index 6ea531d..0000000 Binary files a/data/recordings/20260519_154636_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_154636_user.wav b/data/recordings/20260519_154636_user.wav deleted file mode 100644 index efae78f..0000000 Binary files a/data/recordings/20260519_154636_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154640_robot.wav b/data/recordings/20260519_154640_robot.wav deleted file mode 100644 index c3b1b3f..0000000 Binary files a/data/recordings/20260519_154640_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_154640_user.wav b/data/recordings/20260519_154640_user.wav deleted file mode 100644 index a23a003..0000000 Binary files a/data/recordings/20260519_154640_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154703_user.wav b/data/recordings/20260519_154703_user.wav deleted file mode 100644 index 2752de6..0000000 Binary files a/data/recordings/20260519_154703_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154705_robot.wav b/data/recordings/20260519_154705_robot.wav deleted file mode 100644 index 8c2ebd2..0000000 Binary files a/data/recordings/20260519_154705_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_154705_user.wav b/data/recordings/20260519_154705_user.wav deleted file mode 100644 index b95a9e4..0000000 Binary files a/data/recordings/20260519_154705_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154711_user.wav b/data/recordings/20260519_154711_user.wav deleted file mode 100644 index da16568..0000000 Binary files a/data/recordings/20260519_154711_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154712_robot.wav b/data/recordings/20260519_154712_robot.wav deleted file mode 100644 index 3f73b93..0000000 Binary files a/data/recordings/20260519_154712_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_154712_user.wav b/data/recordings/20260519_154712_user.wav deleted file mode 100644 index 9eb0e2e..0000000 Binary files a/data/recordings/20260519_154712_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154720_robot.wav b/data/recordings/20260519_154720_robot.wav deleted file mode 100644 index 389c01d..0000000 Binary files a/data/recordings/20260519_154720_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_154720_user.wav b/data/recordings/20260519_154720_user.wav deleted file mode 100644 index 56b4f87..0000000 Binary files a/data/recordings/20260519_154720_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154727_robot.wav b/data/recordings/20260519_154727_robot.wav deleted file mode 100644 index 7d4a6d7..0000000 Binary files a/data/recordings/20260519_154727_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_154727_user.wav b/data/recordings/20260519_154727_user.wav deleted file mode 100644 index 57ed818..0000000 Binary files a/data/recordings/20260519_154727_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154733_robot.wav b/data/recordings/20260519_154733_robot.wav deleted file mode 100644 index dc3ac89..0000000 Binary files a/data/recordings/20260519_154733_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_154733_user.wav b/data/recordings/20260519_154733_user.wav deleted file mode 100644 index 0949d2d..0000000 Binary files a/data/recordings/20260519_154733_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154737_robot.wav b/data/recordings/20260519_154737_robot.wav deleted file mode 100644 index 109877d..0000000 Binary files a/data/recordings/20260519_154737_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_154737_user.wav b/data/recordings/20260519_154737_user.wav deleted file mode 100644 index 3185ff3..0000000 Binary files a/data/recordings/20260519_154737_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154740_robot.wav b/data/recordings/20260519_154740_robot.wav deleted file mode 100644 index b64ea83..0000000 Binary files a/data/recordings/20260519_154740_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_154740_user.wav b/data/recordings/20260519_154740_user.wav deleted file mode 100644 index f8a04af..0000000 Binary files a/data/recordings/20260519_154740_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154742_user.wav b/data/recordings/20260519_154742_user.wav deleted file mode 100644 index 5bb72af..0000000 Binary files a/data/recordings/20260519_154742_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154752_robot.wav b/data/recordings/20260519_154752_robot.wav deleted file mode 100644 index 6787590..0000000 Binary files a/data/recordings/20260519_154752_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_154752_user.wav b/data/recordings/20260519_154752_user.wav deleted file mode 100644 index 614ca52..0000000 Binary files a/data/recordings/20260519_154752_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154804_robot.wav b/data/recordings/20260519_154804_robot.wav deleted file mode 100644 index caf44f7..0000000 Binary files a/data/recordings/20260519_154804_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_154804_user.wav b/data/recordings/20260519_154804_user.wav deleted file mode 100644 index 824c9ed..0000000 Binary files a/data/recordings/20260519_154804_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154832_user.wav b/data/recordings/20260519_154832_user.wav deleted file mode 100644 index 818992f..0000000 Binary files a/data/recordings/20260519_154832_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154833_robot.wav b/data/recordings/20260519_154833_robot.wav deleted file mode 100644 index fc0a819..0000000 Binary files a/data/recordings/20260519_154833_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_154833_user.wav b/data/recordings/20260519_154833_user.wav deleted file mode 100644 index 1da3192..0000000 Binary files a/data/recordings/20260519_154833_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154851_user.wav b/data/recordings/20260519_154851_user.wav deleted file mode 100644 index 61437b1..0000000 Binary files a/data/recordings/20260519_154851_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154903_robot.wav b/data/recordings/20260519_154903_robot.wav deleted file mode 100644 index c2d96f1..0000000 Binary files a/data/recordings/20260519_154903_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_154903_user.wav b/data/recordings/20260519_154903_user.wav deleted file mode 100644 index 113196a..0000000 Binary files a/data/recordings/20260519_154903_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154918_user.wav b/data/recordings/20260519_154918_user.wav deleted file mode 100644 index 8c320ab..0000000 Binary files a/data/recordings/20260519_154918_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154949_user.wav b/data/recordings/20260519_154949_user.wav deleted file mode 100644 index 3d78a90..0000000 Binary files a/data/recordings/20260519_154949_user.wav and /dev/null differ diff --git a/data/recordings/20260519_154955_robot.wav b/data/recordings/20260519_154955_robot.wav deleted file mode 100644 index 4f494b9..0000000 Binary files a/data/recordings/20260519_154955_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_154955_user.wav b/data/recordings/20260519_154955_user.wav deleted file mode 100644 index f56b210..0000000 Binary files a/data/recordings/20260519_154955_user.wav and /dev/null differ diff --git a/data/recordings/20260519_155008_robot.wav b/data/recordings/20260519_155008_robot.wav deleted file mode 100644 index 286e435..0000000 Binary files a/data/recordings/20260519_155008_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_155008_user.wav b/data/recordings/20260519_155008_user.wav deleted file mode 100644 index b7993e1..0000000 Binary files a/data/recordings/20260519_155008_user.wav and /dev/null differ diff --git a/data/recordings/20260519_155015_robot.wav b/data/recordings/20260519_155015_robot.wav deleted file mode 100644 index 9c5baa2..0000000 Binary files a/data/recordings/20260519_155015_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_155015_user.wav b/data/recordings/20260519_155015_user.wav deleted file mode 100644 index bd579a2..0000000 Binary files a/data/recordings/20260519_155015_user.wav and /dev/null differ diff --git a/data/recordings/20260519_155026_robot.wav b/data/recordings/20260519_155026_robot.wav deleted file mode 100644 index f765eb1..0000000 Binary files a/data/recordings/20260519_155026_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_155026_user.wav b/data/recordings/20260519_155026_user.wav deleted file mode 100644 index a8651b7..0000000 Binary files a/data/recordings/20260519_155026_user.wav and /dev/null differ diff --git a/data/recordings/20260519_155029_robot.wav b/data/recordings/20260519_155029_robot.wav deleted file mode 100644 index 6fe0bdd..0000000 Binary files a/data/recordings/20260519_155029_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_155029_user.wav b/data/recordings/20260519_155029_user.wav deleted file mode 100644 index 52c218f..0000000 Binary files a/data/recordings/20260519_155029_user.wav and /dev/null differ diff --git a/data/recordings/20260519_155101_user.wav b/data/recordings/20260519_155101_user.wav deleted file mode 100644 index ef0e69f..0000000 Binary files a/data/recordings/20260519_155101_user.wav and /dev/null differ diff --git a/data/recordings/20260519_155251_user.wav b/data/recordings/20260519_155251_user.wav deleted file mode 100644 index 3be713c..0000000 Binary files a/data/recordings/20260519_155251_user.wav and /dev/null differ diff --git a/data/recordings/20260519_155317_robot.wav b/data/recordings/20260519_155317_robot.wav deleted file mode 100644 index 5f8df05..0000000 Binary files a/data/recordings/20260519_155317_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_155317_user.wav b/data/recordings/20260519_155317_user.wav deleted file mode 100644 index acf4221..0000000 Binary files a/data/recordings/20260519_155317_user.wav and /dev/null differ diff --git a/data/recordings/20260519_155328_user.wav b/data/recordings/20260519_155328_user.wav deleted file mode 100644 index 47ed111..0000000 Binary files a/data/recordings/20260519_155328_user.wav and /dev/null differ diff --git a/data/recordings/20260519_155616_robot.wav b/data/recordings/20260519_155616_robot.wav deleted file mode 100644 index 64e9c75..0000000 Binary files a/data/recordings/20260519_155616_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_155616_user.wav b/data/recordings/20260519_155616_user.wav deleted file mode 100644 index 7ef7f75..0000000 Binary files a/data/recordings/20260519_155616_user.wav and /dev/null differ diff --git a/data/recordings/20260519_155622_user.wav b/data/recordings/20260519_155622_user.wav deleted file mode 100644 index cb955cc..0000000 Binary files a/data/recordings/20260519_155622_user.wav and /dev/null differ diff --git a/data/recordings/20260519_155625_robot.wav b/data/recordings/20260519_155625_robot.wav deleted file mode 100644 index 76f29fc..0000000 Binary files a/data/recordings/20260519_155625_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_155625_user.wav b/data/recordings/20260519_155625_user.wav deleted file mode 100644 index 2648df1..0000000 Binary files a/data/recordings/20260519_155625_user.wav and /dev/null differ diff --git a/data/recordings/20260519_155641_robot.wav b/data/recordings/20260519_155641_robot.wav deleted file mode 100644 index 19dff72..0000000 Binary files a/data/recordings/20260519_155641_robot.wav and /dev/null differ diff --git a/data/recordings/20260519_155641_user.wav b/data/recordings/20260519_155641_user.wav deleted file mode 100644 index 6c32742..0000000 Binary files a/data/recordings/20260519_155641_user.wav and /dev/null differ diff --git a/data/recordings/20260520_113905_robot.wav b/data/recordings/20260520_113905_robot.wav deleted file mode 100644 index ea85923..0000000 Binary files a/data/recordings/20260520_113905_robot.wav and /dev/null differ diff --git a/data/recordings/20260520_113914_robot.wav b/data/recordings/20260520_113914_robot.wav deleted file mode 100644 index c5483e5..0000000 Binary files a/data/recordings/20260520_113914_robot.wav and /dev/null differ diff --git a/data/recordings/20260520_113914_user.wav b/data/recordings/20260520_113914_user.wav deleted file mode 100644 index a0b5d82..0000000 Binary files a/data/recordings/20260520_113914_user.wav and /dev/null differ diff --git a/data/recordings/20260520_121411_user.wav b/data/recordings/20260520_121411_user.wav deleted file mode 100644 index 16ce812..0000000 Binary files a/data/recordings/20260520_121411_user.wav and /dev/null differ diff --git a/data/recordings/20260520_121412_robot.wav b/data/recordings/20260520_121412_robot.wav deleted file mode 100644 index 1786ffc..0000000 Binary files a/data/recordings/20260520_121412_robot.wav and /dev/null differ diff --git a/data/recordings/20260520_121412_user.wav b/data/recordings/20260520_121412_user.wav deleted file mode 100644 index 9d1e7cb..0000000 Binary files a/data/recordings/20260520_121412_user.wav and /dev/null differ diff --git a/data/recordings/20260528_164730_robot.wav b/data/recordings/20260528_164730_robot.wav deleted file mode 100644 index 9a514fb..0000000 Binary files a/data/recordings/20260528_164730_robot.wav and /dev/null differ diff --git a/data/recordings/20260528_164732_robot.wav b/data/recordings/20260528_164732_robot.wav deleted file mode 100644 index b760c4e..0000000 Binary files a/data/recordings/20260528_164732_robot.wav and /dev/null differ diff --git a/data/recordings/20260528_164732_user.wav b/data/recordings/20260528_164732_user.wav deleted file mode 100644 index f89abab..0000000 Binary files a/data/recordings/20260528_164732_user.wav and /dev/null differ diff --git a/data/recordings/20260528_174103_robot.wav b/data/recordings/20260528_174103_robot.wav deleted file mode 100644 index 487686f..0000000 Binary files a/data/recordings/20260528_174103_robot.wav and /dev/null differ diff --git a/data/recordings/20260528_174103_user.wav b/data/recordings/20260528_174103_user.wav deleted file mode 100644 index a49ddbc..0000000 Binary files a/data/recordings/20260528_174103_user.wav and /dev/null differ diff --git a/data/recordings/index.json b/data/recordings/index.json index b9e7f8e..fbff38b 100644 --- a/data/recordings/index.json +++ b/data/recordings/index.json @@ -1,3859 +1,4 @@ { - "records": [ - { - "timestamp": "20260514_174629", - "started_at": 1778751989.0267973, - "user_text": "", - "robot_text": "مرحبا، يا خوي! شخبارك؟ أنا موجود هني عشان أساعدك.", - "robot_wav": "/home/unitree/Sanad/data/recordings/20260514_174629_robot.wav", - "robot_duration_sec": 4.0 - }, - { - "timestamp": "20260514_174700", - "started_at": 1778752020.5914102, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260514_174700_user.wav", - "user_duration_sec": 0.32 - }, - { - "timestamp": "20260514_174701", - "started_at": 1778752021.0626216, - "user_text": "", - "robot_text": "مرحبا، يا خوي. شحالك اليوم؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260514_174701_user.wav", - "user_duration_sec": 0.128, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260514_174701_robot.wav", - "robot_duration_sec": 2.4 - }, - { - "timestamp": "20260514_174714", - "started_at": 1778752034.3399298, - "user_text": "", - "robot_text": "يا خوي، الكاميرا انطفت عندي وما أقدر أشوفك الحين. وعقب ما بقدر أميز الوجوه بعد.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260514_174714_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260514_174714_robot.wav", - "robot_duration_sec": 5.76 - }, - { - "timestamp": "20260514_174723", - "started_at": 1778752043.9763718, - "user_text": "", - "robot_text": "هلا، الحين أشوفك زين.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260514_174723_user.wav", - "user_duration_sec": 0.928, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260514_174723_robot.wav", - "robot_duration_sec": 1.8 - }, - { - "timestamp": "20260514_175127", - "started_at": 1778752287.5203393, - "user_text": "", - "robot_text": "تمام، الحين بقدر أميز الوجوه اللي أعرفها.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260514_175127_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260514_175127_robot.wav", - "robot_duration_sec": 3.4 - }, - { - "timestamp": "20260514_175134", - "started_at": 1778752294.0328631, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260514_175134_user.wav", - "user_duration_sec": 0.512 - }, - { - "timestamp": "20260514_175139", - "started_at": 1778752299.8385606, - "user_text": "อัน นั้น มี ", - "robot_text": "هلا والله يا كسام، شحالك؟", - "robot_wav": "/home/unitree/Sanad/data/recordings/20260514_175139_robot.wav", - "robot_duration_sec": 2.08 - }, - { - "timestamp": "20260514_175147", - "started_at": 1778752307.3065, - "user_text": "هذا مين بتعرفه؟", - "robot_text": "هذا قسام! مهندس الروبوتات، صح؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260514_175147_user.wav", - "user_duration_sec": 0.992, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260514_175147_robot.wav", - "robot_duration_sec": 2.84 - }, - { - "timestamp": "20260514_175200", - "started_at": 1778752320.2166772, - "user_text": "هذا مين؟", - "robot_text": "هذا همام! مدير المشاريع.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260514_175200_user.wav", - "user_duration_sec": 0.512, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260514_175200_robot.wav", - "robot_duration_sec": 2.2 - }, - { - "timestamp": "20260514_175213", - "started_at": 1778752333.0210764, - "user_text": "وهذا مين؟", - "robot_text": "السموحة، الصورة ما واضحة كفاية عندي. منو هذا؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260514_175213_user.wav", - "user_duration_sec": 0.704, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260514_175213_robot.wav", - "robot_duration_sec": 3.28 - }, - { - "timestamp": "20260514_175221", - "started_at": 1778752341.1267893, - "user_text": "هذا منهم", - "robot_text": "أوه، هذا حازم! المدير العام في شركة ys lootah tech.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260514_175221_user.wav", - "user_duration_sec": 0.544, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260514_175221_robot.wav", - "robot_duration_sec": 4.48 - }, - { - "timestamp": "20260514_175231", - "started_at": 1778752351.7411609, - "user_text": "شو بتعرف عنه؟", - "robot_text": "عرف إنه المدير العام لشركة ys lootah tech. فيه شيء ثاني تبغي تعرفه؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260514_175231_user.wav", - "user_duration_sec": 0.896, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260514_175231_robot.wav", - "robot_duration_sec": 5.44 - }, - { - "timestamp": "20260514_175244", - "started_at": 1778752364.2121518, - "user_text": "", - "robot_text": "يا خوي، الكاميرا طفت عندي وما أقدر أشوفك الحين، وماراح أقدر أميز الوجوه بعد.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260514_175244_user.wav", - "user_duration_sec": 0.096, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260514_175244_robot.wav", - "robot_duration_sec": 5.44 - }, - { - "timestamp": "20260515_080052", - "started_at": 1778803252.418725, - "user_text": "", - "robot_text": "مرحبا بك، يا خوي. آمِرني بأي شيء تحتاجه.", - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_080052_robot.wav", - "robot_duration_sec": 3.48 - }, - { - "timestamp": "20260515_080056", - "started_at": 1778803256.8518295, - "user_text": "", - "robot_text": "هلا، الحين أشوفك زين. كيف حالك اليوم؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080056_user.wav", - "user_duration_sec": 0.16, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_080056_robot.wav", - "robot_duration_sec": 3.12 - }, - { - "timestamp": "20260515_080201", - "started_at": 1778803321.2681544, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080201_user.wav", - "user_duration_sec": 0.48 - }, - { - "timestamp": "20260515_080205", - "started_at": 1778803325.0990653, - "user_text": "", - "robot_text": "تمام، الحين أقدر أميز الأشخاص اللي أعرفهم.", - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_080205_robot.wav", - "robot_duration_sec": 3.52 - }, - { - "timestamp": "20260515_080209", - "started_at": 1778803329.921542, - "user_text": "", - "robot_text": "تمام، مسجلين عندي. أي شيء ثاني؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080209_user.wav", - "user_duration_sec": 0.128, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_080209_robot.wav", - "robot_duration_sec": 3.08 - }, - { - "timestamp": "20260515_080221", - "started_at": 1778803341.6103332, - "user_text": "", - "robot_text": "...", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080221_user.wav", - "user_duration_sec": 1.088 - }, - { - "timestamp": "20260515_080234", - "started_at": 1778803354.989505, - "user_text": "هذا مين؟ ", - "robot_text": "هلا والله يا صامت، شحالك؟ مصورنا المبدع، عساك بخير؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080234_user.wav", - "user_duration_sec": 1.408, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_080234_robot.wav", - "robot_duration_sec": 4.6 - }, - { - "timestamp": "20260515_080256", - "started_at": 1778803376.1388574, - "user_text": "شو بسوي لك بالعربي؟", - "robot_text": "أنا جاهز لأي مساعدة تحتاجها، سواء معلومات أو ترجمة أو أي شيء ثاني.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080256_user.wav", - "user_duration_sec": 0.96, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_080256_robot.wav", - "robot_duration_sec": 5.48 - }, - { - "timestamp": "20260515_080302", - "started_at": 1778803382.7340183, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080302_user.wav", - "user_duration_sec": 1.76 - }, - { - "timestamp": "20260515_080305", - "started_at": 1778803385.8291006, - "user_text": "شو بس وي لك بال عاد ه مين هون", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080305_user.wav", - "user_duration_sec": 1.952 - }, - { - "timestamp": "20260515_080309", - "started_at": 1778803389.0532322, - "user_text": "ชื่อ แบงค์ ", - "robot_text": "صامت مصورنا اللي يصورك ويحب يمزح وياك شرات ما ذكرت لي.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080309_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_080309_robot.wav", - "robot_duration_sec": 4.6 - }, - { - "timestamp": "20260515_080319", - "started_at": 1778803399.403645, - "user_text": "بن كهه عليك شيء؟", - "robot_text": "ههههه، أكيد! صامت يحب يضيف جو مرح للتصوير. شو بغيت أسويلك الحين؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080319_user.wav", - "user_duration_sec": 0.928, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_080319_robot.wav", - "robot_duration_sec": 6.0 - }, - { - "timestamp": "20260515_080329", - "started_at": 1778803409.5110688, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080329_user.wav", - "user_duration_sec": 2.496 - }, - { - "timestamp": "20260515_080337", - "started_at": 1778803417.7394204, - "user_text": "집어 달라고 그래요.", - "robot_text": "شايف اللوحة اللي فوق الباب؟ مكتوب", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080337_user.wav", - "user_duration_sec": 1.024, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_080337_robot.wav", - "robot_duration_sec": 2.24 - }, - { - "timestamp": "20260515_080341", - "started_at": 1778803421.0030382, - "user_text": "", - "robot_text": "عليها \"IT MANAGER\". يعني صامت ممكن يكون", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080341_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_080341_robot.wav", - "robot_duration_sec": 3.64 - }, - { - "timestamp": "20260515_080342", - "started_at": 1778803422.1499975, - "user_text": "", - "robot_text": "مدير قسم التكنولوجيا عندكم.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080342_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_080342_robot.wav", - "robot_duration_sec": 2.36 - }, - { - "timestamp": "20260515_080345", - "started_at": 1778803425.9800315, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080345_user.wav", - "user_duration_sec": 1.184 - }, - { - "timestamp": "20260515_080348", - "started_at": 1778803428.9489615, - "user_text": "", - "robot_text": "ايه، هو هذا صامت. في شيء ثاني بالك منه؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080348_user.wav", - "user_duration_sec": 1.92, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_080348_robot.wav", - "robot_duration_sec": 3.32 - }, - { - "timestamp": "20260515_080401", - "started_at": 1778803441.7404523, - "user_text": "", - "robot_text": "تم، الحين ما بقدر أميز الوجوه مرة ثانية.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080401_user.wav", - "user_duration_sec": 1.088, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_080401_robot.wav", - "robot_duration_sec": 3.12 - }, - { - "timestamp": "20260515_080433", - "started_at": 1778803473.8520813, - "user_text": "", - "robot_text": "يا خوي، الكاميرا طفت، ما أقدر أشوف أي شيء الحين.", - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_080433_robot.wav", - "robot_duration_sec": 3.56 - }, - { - "timestamp": "20260515_080438", - "started_at": 1778803478.29367, - "user_text": "ఆ", - "robot_text": "ما أقدر أشوف المكتب يا خوي، لازم تشغل الكاميرا من لوحة التحكم.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080438_user.wav", - "user_duration_sec": 7.936, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_080438_robot.wav", - "robot_duration_sec": 4.44 - }, - { - "timestamp": "20260515_080519", - "started_at": 1778803519.1297877, - "user_text": ".", - "robot_text": "مثل ما خبرتك، الكاميرا مبندة، لو سمحت شغلها من لوحة التحكم عشان أقدر أساعدك.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080519_user.wav", - "user_duration_sec": 7.04, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_080519_robot.wav", - "robot_duration_sec": 6.4 - }, - { - "timestamp": "20260515_080555", - "started_at": 1778803555.5243697, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080555_user.wav", - "user_duration_sec": 0.032 - }, - { - "timestamp": "20260515_080555", - "started_at": 1778803555.6336243, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080555_user.wav", - "user_duration_sec": 6.624 - }, - { - "timestamp": "20260515_080615", - "started_at": 1778803575.420792, - "user_text": "خير", - "robot_text": "أكيد أذكر حازم،", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080615_user.wav", - "user_duration_sec": 5.824, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_080615_robot.wav", - "robot_duration_sec": 1.08 - }, - { - "timestamp": "20260515_080626", - "started_at": 1778803586.774901, - "user_text": ".", - "robot_text": "بس مثل ما خبرتك، ما أقدر أشوفه الحين لأن الكاميرا", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080626_user.wav", - "user_duration_sec": 1.472, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_080626_robot.wav", - "robot_duration_sec": 3.08 - }, - { - "timestamp": "20260515_080633", - "started_at": 1778803593.1564424, - "user_text": "", - "robot_text": "مبندة، ومعرفتي", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080633_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_080633_robot.wav", - "robot_duration_sec": 0.84 - }, - { - "timestamp": "20260515_080633", - "started_at": 1778803593.604888, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_080633_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_080633_robot.wav", - "robot_duration_sec": 0.32 - }, - { - "timestamp": "20260515_081625", - "started_at": 1778804185.8479338, - "user_text": "", - "robot_text": "مرحبا يا خوي، يبشر بالخير. أوقات الصلاة في دبي اليوم، الجمعة 15 مايو 2026: الفجر على الساعة 4:15 دقيقة الصبح، الظهر 12:30 دقيقة، العصر 4:05 دقائق، المغرب 6:55 دقيقة، والعشاء 8:05 دقائق.", - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_081625_robot.wav", - "robot_duration_sec": 21.0 - }, - { - "timestamp": "20260515_081915", - "started_at": 1778804355.0008562, - "user_text": "", - "robot_text": "مرحبا يا خوي، شحالك؟ آمِرني، في شي تباه؟", - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_081915_robot.wav", - "robot_duration_sec": 3.4 - }, - { - "timestamp": "20260515_081919", - "started_at": 1778804359.6887746, - "user_text": "", - "robot_text": "هلا والله، الحين أشوفك عدل يا خوي.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_081919_user.wav", - "user_duration_sec": 0.096, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_081919_robot.wav", - "robot_duration_sec": 2.52 - }, - { - "timestamp": "20260515_081926", - "started_at": 1778804366.1183736, - "user_text": "", - "robot_text": "تمام، الحين أقدر أميّز الناس اللي أعرفهم.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_081926_user.wav", - "user_duration_sec": 0.224, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_081926_robot.wav", - "robot_duration_sec": 2.88 - }, - { - "timestamp": "20260515_081931", - "started_at": 1778804371.6329637, - "user_text": "", - "robot_text": "الجو في المكتب حلو اليوم، مرتب وهادي.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_081931_user.wav", - "user_duration_sec": 0.224, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_081931_robot.wav", - "robot_duration_sec": 2.88 - }, - { - "timestamp": "20260515_081937", - "started_at": 1778804377.4027572, - "user_text": "ആദരണീയ", - "robot_text": "ما شاء الله، مكتب مرتب ونظيف.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_081937_user.wav", - "user_duration_sec": 0.512, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_081937_robot.wav", - "robot_duration_sec": 2.64 - }, - { - "timestamp": "20260515_081949", - "started_at": 1778804389.00869, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_081949_user.wav", - "user_duration_sec": 0.032 - }, - { - "timestamp": "20260515_081949", - "started_at": 1778804389.138754, - "user_text": "هذا هذا صوره مين؟", - "robot_text": "هذا أستاذ يوسف سعيد لوتاه، المؤسس والرئيس التنفيذي لشركة لوتاه للوقود الحيوي. بعد مدير في شركات الذكاء الاصطناعي والتكنولوجيا.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_081949_user.wav", - "user_duration_sec": 1.28, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_081949_robot.wav", - "robot_duration_sec": 10.24 - }, - { - "timestamp": "20260515_082011", - "started_at": 1778804411.4102132, - "user_text": "కనీ", - "robot_text": "هلا والله يا قسام، شحالك؟ علومك؟ بعدك منورنا بمشاريع الروبوتات؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_082011_user.wav", - "user_duration_sec": 0.384, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_082011_robot.wav", - "robot_duration_sec": 5.24 - }, - { - "timestamp": "20260515_082024", - "started_at": 1778804424.196041, - "user_text": "", - "robot_text": "عفواً، يا خوي، الكاميرا انطفت وما أقدر أشوف الحين، وبعد ما بقدر أميّز الوجوه.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_082024_user.wav", - "user_duration_sec": 1.056, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_082024_robot.wav", - "robot_duration_sec": 5.6 - }, - { - "timestamp": "20260515_083239", - "started_at": 1778805159.6130798, - "user_text": "", - "robot_text": "مرحبا بك يا خوي. كيف حالك اليوم؟ آمِرْني وش تحتاج؟", - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_083239_robot.wav", - "robot_duration_sec": 4.24 - }, - { - "timestamp": "20260515_083245", - "started_at": 1778805165.2778418, - "user_text": "", - "robot_text": "هلا، الحين أشوفك زين. تفضل، وش بغيت؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_083245_user.wav", - "user_duration_sec": 0.32, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_083245_robot.wav", - "robot_duration_sec": 3.68 - }, - { - "timestamp": "20260515_083253", - "started_at": 1778805173.7585948, - "user_text": "", - "robot_text": "تمام، الحين أقدر أتعرف على الأشخاص.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_083253_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_083253_robot.wav", - "robot_duration_sec": 2.92 - }, - { - "timestamp": "20260515_083300", - "started_at": 1778805180.9703467, - "user_text": ".", - "robot_text": "هلا والله يا همام، شحالك؟ عساك بخير. مشروعك كيف ماشي؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_083300_user.wav", - "user_duration_sec": 1.792, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_083300_robot.wav", - "robot_duration_sec": 4.6 - }, - { - "timestamp": "20260515_083318", - "started_at": 1778805198.9531856, - "user_text": "الحمد لل ه ما شي عا ود", - "robot_text": "تمام الحمد لله. وياك بعد نفس الشيء، الأمور طيبة. منور المكان اليوم.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_083318_user.wav", - "user_duration_sec": 2.144, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_083318_robot.wav", - "robot_duration_sec": 6.28 - }, - { - "timestamp": "20260515_083329", - "started_at": 1778805209.3082454, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_083329_user.wav", - "user_duration_sec": 0.224 - }, - { - "timestamp": "20260515_083330", - "started_at": 1778805210.2653205, - "user_text": ".", - "robot_text": "العفو يا همام، ماسوينا شيء. إذا احتجت أي شيء أنا موجود.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_083330_user.wav", - "user_duration_sec": 0.832, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_083330_robot.wav", - "robot_duration_sec": 4.84 - }, - { - "timestamp": "20260515_083338", - "started_at": 1778805218.2573886, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_083338_user.wav", - "user_duration_sec": 0.256 - }, - { - "timestamp": "20260515_083338", - "started_at": 1778805218.6545627, - "user_text": "انا انا مين انا مين انا فين؟", - "robot_text": "أنت همام، مدير المشاريع. ومثل ما أشوف، أنت الحين في المكتب.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_083338_user.wav", - "user_duration_sec": 0.96, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_083338_robot.wav", - "robot_duration_sec": 4.88 - }, - { - "timestamp": "20260515_083345", - "started_at": 1778805225.5045438, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_083345_user.wav", - "user_duration_sec": 1.824 - }, - { - "timestamp": "20260515_083348", - "started_at": 1778805228.5722678, - "user_text": ". Min min kjø re min er du den rapper?", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_083348_user.wav", - "user_duration_sec": 2.976 - }, - { - "timestamp": "20260515_083417", - "started_at": 1778805257.6116493, - "user_text": " అనవమి", - "robot_text": "هلا والله يا قسام، شحالك؟ مهندس الروبوتات منورنا. منو اللي وياك؟", - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_083417_robot.wav", - "robot_duration_sec": 5.68 - }, - { - "timestamp": "20260515_083421", - "started_at": 1778805261.4056509, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_083421_user.wav", - "user_duration_sec": 0.256 - }, - { - "timestamp": "20260515_083421", - "started_at": 1778805261.8769617, - "user_text": ".", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_083421_user.wav", - "user_duration_sec": 2.592 - }, - { - "timestamp": "20260515_083434", - "started_at": 1778805274.1959803, - "user_text": "", - "robot_text": "هذا همام", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_083434_user.wav", - "user_duration_sec": 0.448 - }, - { - "timestamp": "20260515_083435", - "started_at": 1778805275.610895, - "user_text": "", - "robot_text": "لا هذا قسام يا همام، مهندس الروبوتات. هلا بك والله يا قسام، شعلومك؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_083435_user.wav", - "user_duration_sec": 0.32, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_083435_robot.wav", - "robot_duration_sec": 5.76 - }, - { - "timestamp": "20260515_083446", - "started_at": 1778805286.5930977, - "user_text": "", - "robot_text": "تمام، الحين ما بقدر أتعرف على الوجوه. مرحبا بالضيف اللي وياك بعد.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_083446_user.wav", - "user_duration_sec": 0.288, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_083446_robot.wav", - "robot_duration_sec": 4.92 - }, - { - "timestamp": "20260515_083454", - "started_at": 1778805294.0147464, - "user_text": "", - "robot_text": "disillusioned وياي، يا خوي. الكاميرا انطفت الحين وما أقدر أشوف شيء.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_083454_user.wav", - "user_duration_sec": 0.16, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_083454_robot.wav", - "robot_duration_sec": 4.6 - }, - { - "timestamp": "20260515_084552", - "started_at": 1778805952.7000039, - "user_text": "", - "robot_text": "مرحبا! عشان أقولك كيف الجو، وين مكانك بالضبط؟ وعن المشي، عادة أحسن وقت الصبح أول ما تنش أو متأخر في الليل يوم الجو يبرد شوي.", - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_084552_robot.wav", - "robot_duration_sec": 10.36 - }, - { - "timestamp": "20260515_092655", - "started_at": 1778808415.8625035, - "user_text": "", - "robot_text": "مرحبا يا خوي. تمام، أنا", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092655_user.wav", - "user_duration_sec": 0.384, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_092655_robot.wav", - "robot_duration_sec": 1.76 - }, - { - "timestamp": "20260515_092658", - "started_at": 1778808418.7591166, - "user_text": "", - "robot_text": "جاهز. كيف أقدر أساعدك؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092658_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_092658_robot.wav", - "robot_duration_sec": 2.24 - }, - { - "timestamp": "20260515_092659", - "started_at": 1778808419.2552266, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092659_user.wav", - "user_duration_sec": 1.152 - }, - { - "timestamp": "20260515_092702", - "started_at": 1778808422.378509, - "user_text": "", - "robot_text": "هلا، الحين أشوفك زين. أمورنا طيبة.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092702_user.wav", - "user_duration_sec": 1.088, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_092702_robot.wav", - "robot_duration_sec": 3.04 - }, - { - "timestamp": "20260515_092713", - "started_at": 1778808433.4802406, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092713_user.wav", - "user_duration_sec": 0.192 - }, - { - "timestamp": "20260515_092713", - "started_at": 1778808433.9190485, - "user_text": "う う う 。", - "robot_text": "تمام، الحين أقدر أميز الأشخاص اللي أعرفهم.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092713_user.wav", - "user_duration_sec": 1.152, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_092713_robot.wav", - "robot_duration_sec": 3.0 - }, - { - "timestamp": "20260515_092725", - "started_at": 1778808445.449916, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092725_user.wav", - "user_duration_sec": 0.192 - }, - { - "timestamp": "20260515_092727", - "started_at": 1778808447.2239919, - "user_text": "Так.", - "robot_text": "أقدر أميز الأشخاص اللي أعرفهم. بس حالياً ما أشوف حد واضح قدامي.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092727_user.wav", - "user_duration_sec": 0.48, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_092727_robot.wav", - "robot_duration_sec": 4.68 - }, - { - "timestamp": "20260515_092732", - "started_at": 1778808452.5665224, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092732_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_092732_robot.wav", - "robot_duration_sec": 0.48 - }, - { - "timestamp": "20260515_092733", - "started_at": 1778808453.7777894, - "user_text": ".", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092733_user.wav", - "user_duration_sec": 1.216 - }, - { - "timestamp": "20260515_092741", - "started_at": 1778808461.4609826, - "user_text": "1 2 3 4", - "robot_text": "مرحبا بك يا ضيفنا، شرفتنا.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092741_user.wav", - "user_duration_sec": 0.8, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_092741_robot.wav", - "robot_duration_sec": 2.36 - }, - { - "timestamp": "20260515_092752", - "started_at": 1778808472.1780024, - "user_text": "", - "robot_text": "هلا والله يا قصي، شحالك؟ ويا مرحبا بالضيفة الكريمة.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092752_user.wav", - "user_duration_sec": 4.8, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_092752_robot.wav", - "robot_duration_sec": 3.92 - }, - { - "timestamp": "20260515_092806", - "started_at": 1778808486.6427715, - "user_text": "speak in English.", - "robot_text": "Sure thing! How can I help you both today?", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092806_user.wav", - "user_duration_sec": 1.568, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_092806_robot.wav", - "robot_duration_sec": 2.64 - }, - { - "timestamp": "20260515_092814", - "started_at": 1778808494.9139514, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092814_user.wav", - "user_duration_sec": 7.392 - }, - { - "timestamp": "20260515_092840", - "started_at": 1778808520.1893198, - "user_text": "?", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092840_user.wav", - "user_duration_sec": 3.264 - }, - { - "timestamp": "20260515_092846", - "started_at": 1778808526.4298105, - "user_text": "O kay , yeah , ye ah , the pic ture , yeah.", - "robot_text": "I'm sorry,", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092846_user.wav", - "user_duration_sec": 2.752 - }, - { - "timestamp": "20260515_092853", - "started_at": 1778808533.2171433, - "user_text": "I don' t know if you can re mind by di fferen t pic tures for one person. I did it.", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092853_user.wav", - "user_duration_sec": 4.256 - }, - { - "timestamp": "20260515_092900", - "started_at": 1778808540.3006194, - "user_text": "", - "robot_text": "Yes, I can recognize people based on different pictures now. Is there someone you'd like me to verify?", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092900_user.wav", - "user_duration_sec": 0.256, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_092900_robot.wav", - "robot_duration_sec": 6.4 - }, - { - "timestamp": "20260515_092910", - "started_at": 1778808550.8778107, - "user_text": "Gre at, you are amazing.", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092910_user.wav", - "user_duration_sec": 1.44 - }, - { - "timestamp": "20260515_092915", - "started_at": 1778808555.6748326, - "user_text": "What can you see now?", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092915_user.wav", - "user_duration_sec": 1.408 - }, - { - "timestamp": "20260515_092919", - "started_at": 1778808559.5810645, - "user_text": "باز نفر ", - "robot_text": "I can see you now. It looks like you're in an office environment, with a frosted glass door that says \"IT MANAGER\" on it.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092919_user.wav", - "user_duration_sec": 0.352, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_092919_robot.wav", - "robot_duration_sec": 7.84 - }, - { - "timestamp": "20260515_092931", - "started_at": 1778808571.7623181, - "user_text": "", - "robot_text": "هلا والله يا قصي، شحالك؟ ومرحباً بالضيفة الكريمة.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092931_user.wav", - "user_duration_sec": 1.344, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_092931_robot.wav", - "robot_duration_sec": 3.96 - }, - { - "timestamp": "20260515_092942", - "started_at": 1778808582.4596295, - "user_text": "Let's do that for finish.", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092942_user.wav", - "user_duration_sec": 1.152 - }, - { - "timestamp": "20260515_092945", - "started_at": 1778808585.406496, - "user_text": "Can you see her fa ce? I can defy her face.", - "robot_text": "أكيد، أقدر أشوف وجهها. بس مرحباً بك يا ضيفتنا، ما عندي معلومات عنك في الوقت الحالي.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092945_user.wav", - "user_duration_sec": 3.104, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_092945_robot.wav", - "robot_duration_sec": 6.04 - }, - { - "timestamp": "20260515_092958", - "started_at": 1778808598.3650396, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_092958_user.wav", - "user_duration_sec": 0.8 - }, - { - "timestamp": "20260515_093001", - "started_at": 1778808601.9152248, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093001_user.wav", - "user_duration_sec": 0.544 - }, - { - "timestamp": "20260515_093002", - "started_at": 1778808602.7251916, - "user_text": "Speaking of", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093002_user.wav", - "user_duration_sec": 0.512 - }, - { - "timestamp": "20260515_093006", - "started_at": 1778808606.3671908, - "user_text": "", - "robot_text": "هلا، الحين أقدر أشوفك تمام وأميز", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093006_user.wav", - "user_duration_sec": 0.896, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093006_robot.wav", - "robot_duration_sec": 2.08 - }, - { - "timestamp": "20260515_093008", - "started_at": 1778808608.083727, - "user_text": "", - "robot_text": "الأشخاص.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093008_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093008_robot.wav", - "robot_duration_sec": 1.04 - }, - { - "timestamp": "20260515_093010", - "started_at": 1778808610.8150465, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093010_user.wav", - "user_duration_sec": 0.032 - }, - { - "timestamp": "20260515_093011", - "started_at": 1778808611.0065467, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093011_user.wav", - "user_duration_sec": 0.48 - }, - { - "timestamp": "20260515_093012", - "started_at": 1778808612.4403443, - "user_text": "Now what do you see?", - "robot_text": "ما زلت أشوفك والباب اللي خلفك، بس ما فيه أشخاص ثانيين واضحين جدامي الحين.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093012_user.wav", - "user_duration_sec": 0.384, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093012_robot.wav", - "robot_duration_sec": 5.16 - }, - { - "timestamp": "20260515_093019", - "started_at": 1778808619.2790315, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093019_user.wav", - "user_duration_sec": 1.28 - }, - { - "timestamp": "20260515_093021", - "started_at": 1778808621.231028, - "user_text": "", - "robot_text": "ما أقدر أميز الشخص اللي واقف نالحين. هل تباني أساعدك في شيء ثاني؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093021_user.wav", - "user_duration_sec": 1.344, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093021_robot.wav", - "robot_duration_sec": 4.76 - }, - { - "timestamp": "20260515_093029", - "started_at": 1778808629.9927347, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093029_user.wav", - "user_duration_sec": 0.288 - }, - { - "timestamp": "20260515_093030", - "started_at": 1778808630.5072486, - "user_text": "English please.", - "robot_text": "Sure. There is a person walking past in the background.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093030_user.wav", - "user_duration_sec": 1.472, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093030_robot.wav", - "robot_duration_sec": 3.8 - }, - { - "timestamp": "20260515_093040", - "started_at": 1778808640.8527079, - "user_text": "ป่วย ทําไม คะ", - "robot_text": "I'm sorry, I didn't catch that. Could you please clarify?", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093040_user.wav", - "user_duration_sec": 3.68, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093040_robot.wav", - "robot_duration_sec": 3.76 - }, - { - "timestamp": "20260515_093106", - "started_at": 1778808666.1763866, - "user_text": "", - "robot_text": "مرحبا بك! شحالك يا خوي؟ نورت المكان.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093106_user.wav", - "user_duration_sec": 1.024, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093106_robot.wav", - "robot_duration_sec": 3.28 - }, - { - "timestamp": "20260515_093114", - "started_at": 1778808674.8401976, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093114_user.wav", - "user_duration_sec": 2.592 - }, - { - "timestamp": "20260515_093120", - "started_at": 1778808680.6628096, - "user_text": "Ska du klicka sedan?", - "robot_text": "أشوف حرمة تلبس قبعة وتطالع تلفونها، وخلفها في مكتب.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093120_user.wav", - "user_duration_sec": 0.16, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093120_robot.wav", - "robot_duration_sec": 4.68 - }, - { - "timestamp": "20260515_093126", - "started_at": 1778808686.3805344, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093126_user.wav", - "user_duration_sec": 0.32 - }, - { - "timestamp": "20260515_093126", - "started_at": 1778808686.9214926, - "user_text": "Yes yes. Good sheep.", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093126_user.wav", - "user_duration_sec": 1.568 - }, - { - "timestamp": "20260515_093129", - "started_at": 1778808689.7912679, - "user_text": "세 번 하나요? 벌써 시작.", - "robot_text": "هذي TT WANG، مديرة المبيعات", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093129_user.wav", - "user_duration_sec": 0.832, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093129_robot.wav", - "robot_duration_sec": 1.92 - }, - { - "timestamp": "20260515_093133", - "started_at": 1778808693.2217903, - "user_text": "", - "robot_text": "في Wisemate. هلا والله يا TT، شحالج؟ عساج بخير؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093133_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093133_robot.wav", - "robot_duration_sec": 4.68 - }, - { - "timestamp": "20260515_093137", - "started_at": 1778808697.4870217, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093137_user.wav", - "user_duration_sec": 1.376 - }, - { - "timestamp": "20260515_093139", - "started_at": 1778808699.471395, - "user_text": "Oh , hi . Nice to meet you.", - "robot_text": "تسرك العلوم يا بنت الحلال، منورة الدنيا.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093139_user.wav", - "user_duration_sec": 2.304, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093139_robot.wav", - "robot_duration_sec": 3.12 - }, - { - "timestamp": "20260515_093144", - "started_at": 1778808704.3541894, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093144_user.wav", - "user_duration_sec": 1.44 - }, - { - "timestamp": "20260515_093146", - "started_at": 1778808706.4134073, - "user_text": ".", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093146_user.wav", - "user_duration_sec": 1.216 - }, - { - "timestamp": "20260515_093149", - "started_at": 1778808709.1484604, - "user_text": "", - "robot_text": "Sure thing! How can I help you today?", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093149_user.wav", - "user_duration_sec": 0.256, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093149_robot.wav", - "robot_duration_sec": 1.92 - }, - { - "timestamp": "20260515_093151", - "started_at": 1778808711.835948, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093151_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093151_robot.wav", - "robot_duration_sec": 0.4 - }, - { - "timestamp": "20260515_093152", - "started_at": 1778808712.2246015, - "user_text": "Yeah, mystery.", - "robot_text": "It's great", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093152_user.wav", - "user_duration_sec": 0.8 - }, - { - "timestamp": "20260515_093154", - "started_at": 1778808714.7327406, - "user_text": "โอ ไป นั่ง ทํา", - "robot_text": "It's great to see", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093154_user.wav", - "user_duration_sec": 0.96, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093154_robot.wav", - "robot_duration_sec": 1.08 - }, - { - "timestamp": "20260515_093157", - "started_at": 1778808717.5985093, - "user_text": "", - "robot_text": "to see you again, TT! Is", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093157_user.wav", - "user_duration_sec": 2.688, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093157_robot.wav", - "robot_duration_sec": 2.44 - }, - { - "timestamp": "20260515_093204", - "started_at": 1778808724.0087736, - "user_text": "", - "robot_text": "everything going well?", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093204_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093204_robot.wav", - "robot_duration_sec": 1.48 - }, - { - "timestamp": "20260515_093204", - "started_at": 1778808724.8495002, - "user_text": "and say my name from. Hello.", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093204_user.wav", - "user_duration_sec": 3.84 - }, - { - "timestamp": "20260515_093215", - "started_at": 1778808735.3556094, - "user_text": "เดี๋ยว นะ มี", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093215_user.wav", - "user_duration_sec": 0.224 - }, - { - "timestamp": "20260515_093215", - "started_at": 1778808735.8533013, - "user_text": ".", - "robot_text": "هلا والله ليام! شحالك يا مدير المبيعات في Wisemate؟ منورنا!", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093215_user.wav", - "user_duration_sec": 1.952, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093215_robot.wav", - "robot_duration_sec": 4.52 - }, - { - "timestamp": "20260515_093225", - "started_at": 1778808745.4753637, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093225_user.wav", - "user_duration_sec": 0.32 - }, - { - "timestamp": "20260515_093226", - "started_at": 1778808746.1022458, - "user_text": "E nglish Eng lish", - "robot_text": "Ah, Liam!", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093226_user.wav", - "user_duration_sec": 1.248, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093226_robot.wav", - "robot_duration_sec": 0.96 - }, - { - "timestamp": "20260515_093234", - "started_at": 1778808754.4977877, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093234_user.wav", - "user_duration_sec": 1.376 - }, - { - "timestamp": "20260515_093236", - "started_at": 1778808756.7329848, - "user_text": "guys guys guys", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093236_user.wav", - "user_duration_sec": 1.216 - }, - { - "timestamp": "20260515_093239", - "started_at": 1778808759.427639, - "user_text": "他的電子。 ", - "robot_text": "This looks like an office environment, with desks and partitions. I also see a sign that says \"IT MANAGER\".", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093239_user.wav", - "user_duration_sec": 0.224, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093239_robot.wav", - "robot_duration_sec": 7.8 - }, - { - "timestamp": "20260515_093245", - "started_at": 1778808765.9777915, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093245_user.wav", - "user_duration_sec": 0.32 - }, - { - "timestamp": "20260515_093246", - "started_at": 1778808766.5645595, - "user_text": "sta nding next to you . What is", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093246_user.wav", - "user_duration_sec": 3.264 - }, - { - "timestamp": "20260515_093258", - "started_at": 1778808778.2300367, - "user_text": ".", - "robot_text": "The lady is just standing there, looking at her phone most likely. Is there anything specific you'd like to know?", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093258_user.wav", - "user_duration_sec": 0.256, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093258_robot.wav", - "robot_duration_sec": 6.8 - }, - { - "timestamp": "20260515_093303", - "started_at": 1778808783.7272348, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093303_user.wav", - "user_duration_sec": 0.608 - }, - { - "timestamp": "20260515_093304", - "started_at": 1778808784.760625, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093304_user.wav", - "user_duration_sec": 1.28 - }, - { - "timestamp": "20260515_093307", - "started_at": 1778808787.5414374, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093307_user.wav", - "user_duration_sec": 2.816 - }, - { - "timestamp": "20260515_093311", - "started_at": 1778808791.693397, - "user_text": "Okay . Now do you see Liam?", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093311_user.wav", - "user_duration_sec": 0.96 - }, - { - "timestamp": "20260515_093314", - "started_at": 1778808794.2517202, - "user_text": "Can you see it?", - "robot_text": "Yes, I can see Liam now! Hello again, Liam! How are things today?", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093314_user.wav", - "user_duration_sec": 0.192, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093314_robot.wav", - "robot_duration_sec": 4.48 - }, - { - "timestamp": "20260515_093322", - "started_at": 1778808802.0323343, - "user_text": "", - "robot_text": "That is Liam again, the Sales", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093322_user.wav", - "user_duration_sec": 3.744, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093322_robot.wav", - "robot_duration_sec": 1.64 - }, - { - "timestamp": "20260515_093330", - "started_at": 1778808810.0503805, - "user_text": "", - "robot_text": "Director from Wisemate. We just greeted him!", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093330_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093330_robot.wav", - "robot_duration_sec": 3.28 - }, - { - "timestamp": "20260515_093332", - "started_at": 1778808812.4769826, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093332_user.wav", - "user_duration_sec": 1.76 - }, - { - "timestamp": "20260515_093334", - "started_at": 1778808814.7510154, - "user_text": "", - "robot_text": "هلا حازم، شحالك يا مدير عام", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093334_user.wav", - "user_duration_sec": 7.072, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093334_robot.wav", - "robot_duration_sec": 1.88 - }, - { - "timestamp": "20260515_093357", - "started_at": 1778808837.309134, - "user_text": "", - "robot_text": "في ys lootah tech؟ منور المكان اليوم!", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093357_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093357_robot.wav", - "robot_duration_sec": 3.16 - }, - { - "timestamp": "20260515_093402", - "started_at": 1778808842.713585, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093402_user.wav", - "user_duration_sec": 2.24 - }, - { - "timestamp": "20260515_093410", - "started_at": 1778808850.5478997, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093410_user.wav", - "user_duration_sec": 1.536 - }, - { - "timestamp": "20260515_093413", - "started_at": 1778808853.1925826, - "user_text": "es pe cially for the language", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093413_user.wav", - "user_duration_sec": 1.248 - }, - { - "timestamp": "20260515_093415", - "started_at": 1778808855.822921, - "user_text": "", - "robot_text": "Welcome, Hazem!", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093415_user.wav", - "user_duration_sec": 3.168 - }, - { - "timestamp": "20260515_093422", - "started_at": 1778808862.8056698, - "user_text": "", - "robot_text": "You haven't been in a while! How's the General Manager position going at ys lootah tech?", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093422_user.wav", - "user_duration_sec": 1.056, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093422_robot.wav", - "robot_duration_sec": 5.2 - }, - { - "timestamp": "20260515_093429", - "started_at": 1778808869.3247516, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093429_user.wav", - "user_duration_sec": 0.384 - }, - { - "timestamp": "20260515_093429", - "started_at": 1778808869.9292102, - "user_text": "to like what to mean.", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093429_user.wav", - "user_duration_sec": 2.176 - }, - { - "timestamp": "20260515_093435", - "started_at": 1778808875.4767356, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093435_user.wav", - "user_duration_sec": 0.288 - }, - { - "timestamp": "20260515_093435", - "started_at": 1778808875.9400072, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093435_user.wav", - "user_duration_sec": 4.544 - }, - { - "timestamp": "20260515_093442", - "started_at": 1778808882.6966286, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093442_user.wav", - "user_duration_sec": 4.608 - }, - { - "timestamp": "20260515_093450", - "started_at": 1778808890.574166, - "user_text": "طب ما كت سموز.", - "robot_text": "Sounds good! Is there anything", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093450_user.wav", - "user_duration_sec": 2.464, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093450_robot.wav", - "robot_duration_sec": 1.84 - }, - { - "timestamp": "20260515_093454", - "started_at": 1778808894.9215496, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093454_user.wav", - "user_duration_sec": 0.576 - }, - { - "timestamp": "20260515_093456", - "started_at": 1778808896.5344315, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093456_user.wav", - "user_duration_sec": 13.44 - }, - { - "timestamp": "20260515_093519", - "started_at": 1778808919.7640326, - "user_text": "", - "robot_text": "هلا والله يا قسام، شحالك؟ عسى أمورك كلها طيبة؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093519_user.wav", - "user_duration_sec": 1.376, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093519_robot.wav", - "robot_duration_sec": 3.6 - }, - { - "timestamp": "20260515_093525", - "started_at": 1778808925.0223677, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093525_user.wav", - "user_duration_sec": 0.928 - }, - { - "timestamp": "20260515_093526", - "started_at": 1778808926.753946, - "user_text": ". I'm", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093526_user.wav", - "user_duration_sec": 6.784 - }, - { - "timestamp": "20260515_093537", - "started_at": 1778808937.1660523, - "user_text": "", - "robot_text": "هلا حازم", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093537_user.wav", - "user_duration_sec": 3.328 - }, - { - "timestamp": "20260515_093542", - "started_at": 1778808942.0090811, - "user_text": "for two week s . I just dri ve to uh pi ck up my frien d s in the . I took that.", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093542_user.wav", - "user_duration_sec": 6.432 - }, - { - "timestamp": "20260515_093551", - "started_at": 1778808951.3076293, - "user_text": "So that 's when the situation still", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093551_user.wav", - "user_duration_sec": 2.048 - }, - { - "timestamp": "20260515_093554", - "started_at": 1778808954.6460414, - "user_text": "", - "robot_text": "منور حازم، شحالك يا مدير في ys", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093554_user.wav", - "user_duration_sec": 3.296, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093554_robot.wav", - "robot_duration_sec": 2.12 - }, - { - "timestamp": "20260515_093601", - "started_at": 1778808961.6352406, - "user_text": "", - "robot_text": "lootah tech؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093601_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093601_robot.wav", - "robot_duration_sec": 1.08 - }, - { - "timestamp": "20260515_093602", - "started_at": 1778808962.0792303, - "user_text": "", - "robot_text": "وياك ليام بعد،", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093602_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093602_robot.wav", - "robot_duration_sec": 1.6 - }, - { - "timestamp": "20260515_093602", - "started_at": 1778808962.6601222, - "user_text": "", - "robot_text": "هلا والله!", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093602_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093602_robot.wav", - "robot_duration_sec": 1.0 - }, - { - "timestamp": "20260515_093603", - "started_at": 1778808963.6594806, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093603_user.wav", - "user_duration_sec": 2.176 - }, - { - "timestamp": "20260515_093607", - "started_at": 1778808967.0852342, - "user_text": " Posso ir para trás?", - "robot_text": "العفو، حياكم الله! أي خدمة ثانية تحتاجونها؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093607_user.wav", - "user_duration_sec": 4.448, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093607_robot.wav", - "robot_duration_sec": 3.8 - }, - { - "timestamp": "20260515_093618", - "started_at": 1778808978.5019813, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093618_user.wav", - "user_duration_sec": 0.704 - }, - { - "timestamp": "20260515_093620", - "started_at": 1778808980.1916487, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093620_user.wav", - "user_duration_sec": 14.88 - }, - { - "timestamp": "20260515_093703", - "started_at": 1778809023.6500554, - "user_text": "", - "robot_text": "العفو، بالتوفيق", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093703_user.wav", - "user_duration_sec": 1.824, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093703_robot.wav", - "robot_duration_sec": 1.0 - }, - { - "timestamp": "20260515_093707", - "started_at": 1778809027.8983266, - "user_text": "Let me have you.", - "robot_text": "يا هلا بـ TT WANG! منورة شركة Wisemate،", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093707_user.wav", - "user_duration_sec": 1.984, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093707_robot.wav", - "robot_duration_sec": 2.72 - }, - { - "timestamp": "20260515_093711", - "started_at": 1778809031.0617394, - "user_text": "", - "robot_text": "شحالج؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093711_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093711_robot.wav", - "robot_duration_sec": 1.72 - }, - { - "timestamp": "20260515_093715", - "started_at": 1778809035.325638, - "user_text": "他 不 是 如 果 要 狗 嗎 ?我 說 送 你 個 價。", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093715_user.wav", - "user_duration_sec": 7.072 - }, - { - "timestamp": "20260515_093729", - "started_at": 1778809049.0277343, - "user_text": "", - "robot_text": "بالتوفيق ان شاء الله!", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093729_user.wav", - "user_duration_sec": 1.184, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093729_robot.wav", - "robot_duration_sec": 1.4 - }, - { - "timestamp": "20260515_093735", - "started_at": 1778809055.755901, - "user_text": "Ja.", - "robot_text": "مع السلامة وكل التوفيق ان شاء شاء الله.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093735_user.wav", - "user_duration_sec": 6.496, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093735_robot.wav", - "robot_duration_sec": 2.16 - }, - { - "timestamp": "20260515_093808", - "started_at": 1778809088.0410483, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093808_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093808_robot.wav", - "robot_duration_sec": 0.24 - }, - { - "timestamp": "20260515_093808", - "started_at": 1778809088.7037902, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093808_user.wav", - "user_duration_sec": 1.28 - }, - { - "timestamp": "20260515_093812", - "started_at": 1778809092.058754, - "user_text": "ตอบ หรือ ยัง เฮ้ย", - "robot_text": "ربي يحفظكم، في أمان الله.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093812_user.wav", - "user_duration_sec": 0.992, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093812_robot.wav", - "robot_duration_sec": 2.08 - }, - { - "timestamp": "20260515_093820", - "started_at": 1778809100.1867275, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093820_user.wav", - "user_duration_sec": 0.064 - }, - { - "timestamp": "20260515_093820", - "started_at": 1778809100.411773, - "user_text": ".", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093820_user.wav", - "user_duration_sec": 7.584 - }, - { - "timestamp": "20260515_093833", - "started_at": 1778809113.6258783, - "user_text": ". Bun. Bun.", - "robot_text": "حياكم الله كلكم! شخباركم؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093833_user.wav", - "user_duration_sec": 2.592, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093833_robot.wav", - "robot_duration_sec": 2.0 - }, - { - "timestamp": "20260515_093838", - "started_at": 1778809118.4745846, - "user_text": "", - "robot_text": "عساكم بخير؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093838_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093838_robot.wav", - "robot_duration_sec": 1.44 - }, - { - "timestamp": "20260515_093838", - "started_at": 1778809118.8931139, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093838_user.wav", - "user_duration_sec": 1.792 - }, - { - "timestamp": "20260515_093841", - "started_at": 1778809121.7110543, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093841_user.wav", - "user_duration_sec": 5.6 - }, - { - "timestamp": "20260515_093853", - "started_at": 1778809133.1401792, - "user_text": "Havyes mm", - "robot_text": "تمام، أشوفكم على خير إن شاء الله.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093853_user.wav", - "user_duration_sec": 1.024, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093853_robot.wav", - "robot_duration_sec": 2.12 - }, - { - "timestamp": "20260515_093856", - "started_at": 1778809136.644915, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093856_user.wav", - "user_duration_sec": 1.024 - }, - { - "timestamp": "20260515_093858", - "started_at": 1778809138.8834007, - "user_text": "", - "robot_text": "بالتوفيق! مع السلامة.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093858_user.wav", - "user_duration_sec": 1.28, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093858_robot.wav", - "robot_duration_sec": 1.64 - }, - { - "timestamp": "20260515_093901", - "started_at": 1778809141.2846725, - "user_text": ".", - "robot_text": "حياكم الله!", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093901_user.wav", - "user_duration_sec": 0.736, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093901_robot.wav", - "robot_duration_sec": 1.12 - }, - { - "timestamp": "20260515_093907", - "started_at": 1778809147.8680582, - "user_text": "", - "robot_text": "للأسف، الكاميرا انطفت وما بقدر أشوف كل اللي في الغرفة الحين، وعقب ما بقدر أميز الوجوه لو اشتغلت مرة ثانية.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_093907_user.wav", - "user_duration_sec": 8.384, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_093907_robot.wav", - "robot_duration_sec": 7.4 - }, - { - "timestamp": "20260515_094006", - "started_at": 1778809206.6012864, - "user_text": "Thank you so much.", - "robot_text": "العفو، أي وقت!", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094006_user.wav", - "user_duration_sec": 0.896, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094006_robot.wav", - "robot_duration_sec": 1.6 - }, - { - "timestamp": "20260515_094013", - "started_at": 1778809213.5665903, - "user_text": "", - "robot_text": "في أمان الله.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094013_user.wav", - "user_duration_sec": 4.64, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094013_robot.wav", - "robot_duration_sec": 1.0 - }, - { - "timestamp": "20260515_094024", - "started_at": 1778809224.8354383, - "user_text": "", - "robot_text": "هلا، الحين أشوفك زين، ومبين", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094024_user.wav", - "user_duration_sec": 1.504, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094024_robot.wav", - "robot_duration_sec": 1.76 - }, - { - "timestamp": "20260515_094030", - "started_at": 1778809230.808465, - "user_text": "", - "robot_text": "عندي بعد منو وياك.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094030_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094030_robot.wav", - "robot_duration_sec": 2.28 - }, - { - "timestamp": "20260515_094033", - "started_at": 1778809233.5565565, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094033_user.wav", - "user_duration_sec": 0.768 - }, - { - "timestamp": "20260515_094034", - "started_at": 1778809234.5569935, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094034_user.wav", - "user_duration_sec": 0.192 - }, - { - "timestamp": "20260515_094035", - "started_at": 1778809235.6109018, - "user_text": "", - "robot_text": "تمام، في انتظار", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094035_user.wav", - "user_duration_sec": 1.152 - }, - { - "timestamp": "20260515_094037", - "started_at": 1778809237.8618693, - "user_text": "อืม", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094037_user.wav", - "user_duration_sec": 1.568 - }, - { - "timestamp": "20260515_094041", - "started_at": 1778809241.3059475, - "user_text": "", - "robot_text": "هلا والله بحازم، منور يا مدير! وشحالك اليوم؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094041_user.wav", - "user_duration_sec": 1.856, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094041_robot.wav", - "robot_duration_sec": 3.84 - }, - { - "timestamp": "20260515_094048", - "started_at": 1778809248.4348354, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094048_user.wav", - "user_duration_sec": 0.192 - }, - { - "timestamp": "20260515_094049", - "started_at": 1778809249.749145, - "user_text": "", - "robot_text": "هلا قسام، شخبارك؟ عساك بخير ومرتاح يا مهندس الروبوتات؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094049_user.wav", - "user_duration_sec": 1.344, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094049_robot.wav", - "robot_duration_sec": 4.8 - }, - { - "timestamp": "20260515_094058", - "started_at": 1778809258.7522726, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094058_user.wav", - "user_duration_sec": 0.48 - }, - { - "timestamp": "20260515_094059", - "started_at": 1778809259.6619916, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094059_user.wav", - "user_duration_sec": 14.048 - }, - { - "timestamp": "20260515_094120", - "started_at": 1778809280.8421795, - "user_text": "by the video. ", - "robot_text": "تمام، خذوا راحتكم.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094120_user.wav", - "user_duration_sec": 0.736, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094120_robot.wav", - "robot_duration_sec": 1.56 - }, - { - "timestamp": "20260515_094127", - "started_at": 1778809287.1448166, - "user_text": "", - "robot_text": "بالتوفيق! ونورتوا المكان.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094127_user.wav", - "user_duration_sec": 2.08, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094127_robot.wav", - "robot_duration_sec": 2.24 - }, - { - "timestamp": "20260515_094132", - "started_at": 1778809292.3170307, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094132_user.wav", - "user_duration_sec": 0.512 - }, - { - "timestamp": "20260515_094134", - "started_at": 1778809294.2969973, - "user_text": ".", - "robot_text": "هلا تي تي WANG، شحالج؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094134_user.wav", - "user_duration_sec": 9.408, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094134_robot.wav", - "robot_duration_sec": 1.64 - }, - { - "timestamp": "20260515_094153", - "started_at": 1778809313.3011642, - "user_text": "", - "robot_text": "عسى كل أمورك طيبة", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094153_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094153_robot.wav", - "robot_duration_sec": 1.6 - }, - { - "timestamp": "20260515_094153", - "started_at": 1778809313.7676032, - "user_text": "", - "robot_text": "مديرة المبيعات في Wisemate؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094153_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094153_robot.wav", - "robot_duration_sec": 1.6 - }, - { - "timestamp": "20260515_094154", - "started_at": 1778809314.214365, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094154_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094154_robot.wav", - "robot_duration_sec": 0.52 - }, - { - "timestamp": "20260515_094154", - "started_at": 1778809314.6999533, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094154_user.wav", - "user_duration_sec": 1.568 - }, - { - "timestamp": "20260515_094159", - "started_at": 1778809319.1421852, - "user_text": ". Stop.", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094159_user.wav", - "user_duration_sec": 4.192 - }, - { - "timestamp": "20260515_094209", - "started_at": 1778809329.7506063, - "user_text": "", - "robot_text": "بالتوفيق لكم كلكم.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094209_user.wav", - "user_duration_sec": 1.888, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094209_robot.wav", - "robot_duration_sec": 1.72 - }, - { - "timestamp": "20260515_094216", - "started_at": 1778809336.1121686, - "user_text": "So I will ask them to send word.", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094216_user.wav", - "user_duration_sec": 6.048 - }, - { - "timestamp": "20260515_094230", - "started_at": 1778809350.217812, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094230_user.wav", - "user_duration_sec": 2.08 - }, - { - "timestamp": "20260515_094234", - "started_at": 1778809354.0501943, - "user_text": "Ну, ні, ні, ні, конечно, це", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094234_user.wav", - "user_duration_sec": 0.992 - }, - { - "timestamp": "20260515_094236", - "started_at": 1778809356.8670454, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094236_user.wav", - "user_duration_sec": 1.376 - }, - { - "timestamp": "20260515_094244", - "started_at": 1778809364.2052708, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094244_user.wav", - "user_duration_sec": 0.384 - }, - { - "timestamp": "20260515_094244", - "started_at": 1778809364.8692424, - "user_text": "Oh.", - "robot_text": "هلا والله يا قسام! ومرحباً تي تي وانغ، منورين! شحالكم اليوم؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094244_user.wav", - "user_duration_sec": 1.248, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094244_robot.wav", - "robot_duration_sec": 4.68 - }, - { - "timestamp": "20260515_094256", - "started_at": 1778809376.3023887, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094256_user.wav", - "user_duration_sec": 0.032 - }, - { - "timestamp": "20260515_094256", - "started_at": 1778809376.4119, - "user_text": ".", - "robot_text": ".", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094256_user.wav", - "user_duration_sec": 4.928 - }, - { - "timestamp": "20260515_094324", - "started_at": 1778809404.3362288, - "user_text": "ตัด ตัด ตรง นี้ เลย นะ", - "robot_text": "جو هادي في المكتب،", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094324_user.wav", - "user_duration_sec": 3.456 - }, - { - "timestamp": "20260515_094402", - "started_at": 1778809442.621018, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094402_user.wav", - "user_duration_sec": 8.352 - }, - { - "timestamp": "20260515_094416", - "started_at": 1778809456.6309686, - "user_text": "", - "robot_text": "ما أشوف حد حالياً، بس المكتب مرتب", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094416_user.wav", - "user_duration_sec": 2.784, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094416_robot.wav", - "robot_duration_sec": 2.36 - }, - { - "timestamp": "20260515_094421", - "started_at": 1778809461.4685996, - "user_text": "", - "robot_text": "ورايق.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094421_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094421_robot.wav", - "robot_duration_sec": 1.2 - }, - { - "timestamp": "20260515_094423", - "started_at": 1778809463.4757385, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094423_user.wav", - "user_duration_sec": 0.608 - }, - { - "timestamp": "20260515_094424", - "started_at": 1778809464.9392118, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094424_user.wav", - "user_duration_sec": 21.472 - }, - { - "timestamp": "20260515_094457", - "started_at": 1778809497.9940362, - "user_text": "", - "robot_text": "ما في أي", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094457_user.wav", - "user_duration_sec": 2.72 - }, - { - "timestamp": "20260515_094504", - "started_at": 1778809504.0029688, - "user_text": "Gre at , gre at . We just di d I just nee d to sho w the video today and to show them o kay . al ready and then we can just know um lear n to the know ledge from to do that.", - "robot_text": "تمام، خذوا راحتكم.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094504_user.wav", - "user_duration_sec": 12.224, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094504_robot.wav", - "robot_duration_sec": 1.52 - }, - { - "timestamp": "20260515_094526", - "started_at": 1778809526.083124, - "user_text": "[noise]", - "robot_text": "اوكي، أنا موجود لو احتجتوا أي شيء.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094526_user.wav", - "user_duration_sec": 0.992, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094526_robot.wav", - "robot_duration_sec": 2.44 - }, - { - "timestamp": "20260515_094529", - "started_at": 1778809529.7873862, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094529_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094529_robot.wav", - "robot_duration_sec": 0.12 - }, - { - "timestamp": "20260515_094532", - "started_at": 1778809532.1946657, - "user_text": ". I like to.", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094532_user.wav", - "user_duration_sec": 15.584 - }, - { - "timestamp": "20260515_094616", - "started_at": 1778809576.048343, - "user_text": "So that is like we can if this is there and le ft into one.", - "robot_text": "تمام، اللي يريحكم.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094616_user.wav", - "user_duration_sec": 5.088, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094616_robot.wav", - "robot_duration_sec": 1.56 - }, - { - "timestamp": "20260515_094627", - "started_at": 1778809587.1029334, - "user_text": "აუ სიბიონით ხო?", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094627_user.wav", - "user_duration_sec": 5.568 - }, - { - "timestamp": "20260515_094637", - "started_at": 1778809597.710454, - "user_text": "Oh goo d, all good actua lly . So I 'm going to sho w view s to sho w functions.", - "robot_text": "شفت مهندس الروبوتات قسام مرة ثانية، شكله مشغول بشغله.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094637_user.wav", - "user_duration_sec": 5.504, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094637_robot.wav", - "robot_duration_sec": 4.24 - }, - { - "timestamp": "20260515_094647", - "started_at": 1778809607.3812702, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094647_user.wav", - "user_duration_sec": 1.44 - }, - { - "timestamp": "20260515_094650", - "started_at": 1778809610.885234, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094650_user.wav", - "user_duration_sec": 12.224 - }, - { - "timestamp": "20260515_094713", - "started_at": 1778809633.5971472, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094713_user.wav", - "user_duration_sec": 0.256 - }, - { - "timestamp": "20260515_094714", - "started_at": 1778809634.6655543, - "user_text": ".", - "robot_text": "هلا حازم، شحالك يا مدير؟ عساك بخير.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094714_user.wav", - "user_duration_sec": 0.608, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094714_robot.wav", - "robot_duration_sec": 3.48 - }, - { - "timestamp": "20260515_094724", - "started_at": 1778809644.413377, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094724_user.wav", - "user_duration_sec": 0.096 - }, - { - "timestamp": "20260515_094725", - "started_at": 1778809645.7589042, - "user_text": "You want to see me?", - "robot_text": "هلا ليام، منور الاجتماع!", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094725_user.wav", - "user_duration_sec": 2.816, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094725_robot.wav", - "robot_duration_sec": 2.24 - }, - { - "timestamp": "20260515_094738", - "started_at": 1778809658.549823, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094738_user.wav", - "user_duration_sec": 0.352 - }, - { - "timestamp": "20260515_094739", - "started_at": 1778809659.0492153, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094739_user.wav", - "user_duration_sec": 3.392 - }, - { - "timestamp": "20260515_094751", - "started_at": 1778809671.5759125, - "user_text": "", - "robot_text": "بالتوفيق في اجتماعكم، يا حازم", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094751_user.wav", - "user_duration_sec": 0.864, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094751_robot.wav", - "robot_duration_sec": 2.08 - }, - { - "timestamp": "20260515_094755", - "started_at": 1778809675.6827207, - "user_text": "", - "robot_text": "وليام!", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094755_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094755_robot.wav", - "robot_duration_sec": 0.84 - }, - { - "timestamp": "20260515_094757", - "started_at": 1778809677.7314005, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094757_user.wav", - "user_duration_sec": 0.352 - }, - { - "timestamp": "20260515_094758", - "started_at": 1778809678.3692307, - "user_text": "Shake hands. Shake hands.", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094758_user.wav", - "user_duration_sec": 18.304 - }, - { - "timestamp": "20260515_094831", - "started_at": 1778809711.4078243, - "user_text": ".", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094831_user.wav", - "user_duration_sec": 1.856 - }, - { - "timestamp": "20260515_094834", - "started_at": 1778809714.3034053, - "user_text": ".", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094834_user.wav", - "user_duration_sec": 2.976 - }, - { - "timestamp": "20260515_094839", - "started_at": 1778809719.2034569, - "user_text": "", - "robot_text": "حيّاك الله يا حازم،", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094839_user.wav", - "user_duration_sec": 2.4, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094839_robot.wav", - "robot_duration_sec": 1.64 - }, - { - "timestamp": "20260515_094843", - "started_at": 1778809723.0940247, - "user_text": "", - "robot_text": "ويا هلا بليام. كيف حالكم؟ عسى", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094843_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094843_robot.wav", - "robot_duration_sec": 2.52 - }, - { - "timestamp": "20260515_094843", - "started_at": 1778809723.990518, - "user_text": "", - "robot_text": "كل شيء طيب؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094843_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094843_robot.wav", - "robot_duration_sec": 1.56 - }, - { - "timestamp": "20260515_094844", - "started_at": 1778809724.4064922, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094844_user.wav", - "user_duration_sec": 2.208 - }, - { - "timestamp": "20260515_094848", - "started_at": 1778809728.166173, - "user_text": ".", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094848_user.wav", - "user_duration_sec": 1.28 - }, - { - "timestamp": "20260515_094855", - "started_at": 1778809735.4668992, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094855_user.wav", - "user_duration_sec": 0.352 - }, - { - "timestamp": "20260515_094856", - "started_at": 1778809736.0085597, - "user_text": ".", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094856_user.wav", - "user_duration_sec": 2.624 - }, - { - "timestamp": "20260515_094900", - "started_at": 1778809740.05413, - "user_text": ".", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094900_user.wav", - "user_duration_sec": 3.744 - }, - { - "timestamp": "20260515_094907", - "started_at": 1778809747.5636914, - "user_text": ".", - "robot_text": "مرحبا حازم،", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094907_user.wav", - "user_duration_sec": 3.52 - }, - { - "timestamp": "20260515_094913", - "started_at": 1778809753.4751868, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094913_user.wav", - "user_duration_sec": 6.912 - }, - { - "timestamp": "20260515_094923", - "started_at": 1778809763.6009243, - "user_text": "수 풀 님 . 사 진 으로 하 면 서 شو عم شو", - "robot_text": "هلا والله حازم، شحالك يا مدير؟ وياك ليام", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094923_user.wav", - "user_duration_sec": 6.144, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094923_robot.wav", - "robot_duration_sec": 3.36 - }, - { - "timestamp": "20260515_094934", - "started_at": 1778809774.9002097, - "user_text": "", - "robot_text": "بعد منورين والله.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094934_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094934_robot.wav", - "robot_duration_sec": 1.56 - }, - { - "timestamp": "20260515_094935", - "started_at": 1778809775.3861923, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094935_user.wav", - "user_duration_sec": 2.24 - }, - { - "timestamp": "20260515_094938", - "started_at": 1778809778.8386662, - "user_text": "", - "robot_text": "تنورونا دايماً! كيف الأمور عندكم؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094938_user.wav", - "user_duration_sec": 8.384, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094938_robot.wav", - "robot_duration_sec": 2.52 - }, - { - "timestamp": "20260515_094955", - "started_at": 1778809795.272099, - "user_text": "", - "robot_text": "منورين الجلسة والله، كل شيء تمام؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_094955_user.wav", - "user_duration_sec": 4.48, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_094955_robot.wav", - "robot_duration_sec": 2.0 - }, - { - "timestamp": "20260515_095004", - "started_at": 1778809804.2572916, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095004_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095004_robot.wav", - "robot_duration_sec": 0.4 - }, - { - "timestamp": "20260515_095005", - "started_at": 1778809805.201633, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095005_user.wav", - "user_duration_sec": 0.64 - }, - { - "timestamp": "20260515_095006", - "started_at": 1778809806.3229535, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095006_user.wav", - "user_duration_sec": 5.024 - }, - { - "timestamp": "20260515_095018", - "started_at": 1778809818.265587, - "user_text": "Okay. Also wir dürfen nicht.", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095018_user.wav", - "user_duration_sec": 0.96 - }, - { - "timestamp": "20260515_095020", - "started_at": 1778809820.357888, - "user_text": "", - "robot_text": "حي الله ليام و تي تي شخباركم", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095020_user.wav", - "user_duration_sec": 4.768, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095020_robot.wav", - "robot_duration_sec": 2.16 - }, - { - "timestamp": "20260515_095028", - "started_at": 1778809828.7211938, - "user_text": "", - "robot_text": "عساكم بخير؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095028_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095028_robot.wav", - "robot_duration_sec": 1.4 - }, - { - "timestamp": "20260515_095029", - "started_at": 1778809829.1506586, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095029_user.wav", - "user_duration_sec": 1.76 - }, - { - "timestamp": "20260515_095031", - "started_at": 1778809831.8427076, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095031_user.wav", - "user_duration_sec": 5.376 - }, - { - "timestamp": "20260515_095042", - "started_at": 1778809842.6378486, - "user_text": "", - "robot_text": "جلسة موفقة ان شاء الله، بالتوفيق", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095042_user.wav", - "user_duration_sec": 1.984, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095042_robot.wav", - "robot_duration_sec": 1.52 - }, - { - "timestamp": "20260515_095047", - "started_at": 1778809847.50082, - "user_text": "", - "robot_text": "لكم جميعاً.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095047_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095047_robot.wav", - "robot_duration_sec": 1.52 - }, - { - "timestamp": "20260515_095047", - "started_at": 1778809847.920334, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095047_user.wav", - "user_duration_sec": 1.376 - }, - { - "timestamp": "20260515_095050", - "started_at": 1778809850.33551, - "user_text": ".", - "robot_text": "ما شاء الله، حازم و ليام", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095050_user.wav", - "user_duration_sec": 8.736, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095050_robot.wav", - "robot_duration_sec": 1.76 - }, - { - "timestamp": "20260515_095107", - "started_at": 1778809867.9139411, - "user_text": "", - "robot_text": "وتي تي كلهم موجودين! أكيد", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095107_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095107_robot.wav", - "robot_duration_sec": 1.56 - }, - { - "timestamp": "20260515_095108", - "started_at": 1778809868.5132987, - "user_text": "", - "robot_text": "اجتماع مهم.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095108_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095108_robot.wav", - "robot_duration_sec": 1.6 - }, - { - "timestamp": "20260515_095112", - "started_at": 1778809872.2557647, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095112_user.wav", - "user_duration_sec": 0.032 - }, - { - "timestamp": "20260515_095112", - "started_at": 1778809872.6511655, - "user_text": "", - "robot_text": "فالك طيب وياكم،", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095112_user.wav", - "user_duration_sec": 5.152, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095112_robot.wav", - "robot_duration_sec": 1.64 - }, - { - "timestamp": "20260515_095121", - "started_at": 1778809881.0691955, - "user_text": "", - "robot_text": "أي خدمة", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095121_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095121_robot.wav", - "robot_duration_sec": 0.72 - }, - { - "timestamp": "20260515_095121", - "started_at": 1778809881.4970362, - "user_text": "", - "robot_text": "أنا جاهز.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095121_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095121_robot.wav", - "robot_duration_sec": 0.68 - }, - { - "timestamp": "20260515_095121", - "started_at": 1778809881.931356, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095121_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095121_robot.wav", - "robot_duration_sec": 0.24 - }, - { - "timestamp": "20260515_095123", - "started_at": 1778809883.673065, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095123_user.wav", - "user_duration_sec": 0.224 - }, - { - "timestamp": "20260515_095124", - "started_at": 1778809884.0138052, - "user_text": "沒 有 完 , 他 後 來 說 重 新 再 做 一個啊。 沒關係啊,我們", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095124_user.wav", - "user_duration_sec": 6.112 - }, - { - "timestamp": "20260515_095135", - "started_at": 1778809895.957714, - "user_text": "你 別 管 他 主 要 了 ,我 們 這 個 效 果 就是 他 有 這 個 支 護 能 講 ,今天介紹一個下一個。", - "robot_text": "تمام التمام، خذوا راحتكم.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095135_user.wav", - "user_duration_sec": 7.872, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095135_robot.wav", - "robot_duration_sec": 2.12 - }, - { - "timestamp": "20260515_095148", - "started_at": 1778809908.3383746, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095148_user.wav", - "user_duration_sec": 0.864 - }, - { - "timestamp": "20260515_095150", - "started_at": 1778809910.1511078, - "user_text": ".", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095150_user.wav", - "user_duration_sec": 8.288 - }, - { - "timestamp": "20260515_095217", - "started_at": 1778809937.7767408, - "user_text": ".", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095217_user.wav", - "user_duration_sec": 0.704 - }, - { - "timestamp": "20260515_095223", - "started_at": 1778809943.5721247, - "user_text": ".", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095223_user.wav", - "user_duration_sec": 1.312 - }, - { - "timestamp": "20260515_095227", - "started_at": 1778809947.2134564, - "user_text": " 阿西要 ", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095227_user.wav", - "user_duration_sec": 0.352 - }, - { - "timestamp": "20260515_095228", - "started_at": 1778809948.830938, - "user_text": "a + b介紹的。", - "robot_text": "شكلها بتكون جلسة مثمرة، بالتوفيق لكم!", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095228_user.wav", - "user_duration_sec": 2.432, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095228_robot.wav", - "robot_duration_sec": 3.32 - }, - { - "timestamp": "20260515_095240", - "started_at": 1778809960.5091906, - "user_text": "", - "robot_text": "'Tفضلوا كملوا مدتكم، خذوا راحتكم للاخر.'", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095240_user.wav", - "user_duration_sec": 2.4, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095240_robot.wav", - "robot_duration_sec": 2.6 - }, - { - "timestamp": "20260515_095245", - "started_at": 1778809965.050899, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095245_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095245_robot.wav", - "robot_duration_sec": 0.28 - }, - { - "timestamp": "20260515_095246", - "started_at": 1778809966.0028684, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095246_user.wav", - "user_duration_sec": 0.48 - }, - { - "timestamp": "20260515_095246", - "started_at": 1778809966.7745104, - "user_text": ".", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095246_user.wav", - "user_duration_sec": 2.08 - }, - { - "timestamp": "20260515_095250", - "started_at": 1778809970.2962544, - "user_text": "unglausi.", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095250_user.wav", - "user_duration_sec": 0.48 - }, - { - "timestamp": "20260515_095252", - "started_at": 1778809972.4590547, - "user_text": "他其實當然面對。", - "robot_text": "بالتوفيق في اجتماعكم وموفقين دايم", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095252_user.wav", - "user_duration_sec": 0.672, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095252_robot.wav", - "robot_duration_sec": 3.08 - }, - { - "timestamp": "20260515_095303", - "started_at": 1778809983.983013, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095303_user.wav", - "user_duration_sec": 2.048 - }, - { - "timestamp": "20260515_095311", - "started_at": 1778809991.2751036, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095311_user.wav", - "user_duration_sec": 0.32 - }, - { - "timestamp": "20260515_095312", - "started_at": 1778809992.7278874, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095312_user.wav", - "user_duration_sec": 3.2 - }, - { - "timestamp": "20260515_095319", - "started_at": 1778809999.4196892, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095319_user.wav", - "user_duration_sec": 2.048 - }, - { - "timestamp": "20260515_095323", - "started_at": 1778810003.4062488, - "user_text": "7 0. Eh, visitamos a 100%.", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095323_user.wav", - "user_duration_sec": 2.08 - }, - { - "timestamp": "20260515_095327", - "started_at": 1778810007.0185175, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095327_user.wav", - "user_duration_sec": 0.8 - }, - { - "timestamp": "20260515_095329", - "started_at": 1778810009.2331939, - "user_text": "او عادي حاله", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095329_user.wav", - "user_duration_sec": 0.64 - }, - { - "timestamp": "20260515_095331", - "started_at": 1778810011.081641, - "user_text": "والله ي سلم علي كم خير.", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095331_user.wav", - "user_duration_sec": 2.336 - }, - { - "timestamp": "20260515_095335", - "started_at": 1778810015.4608798, - "user_text": "لو واحد اثنين جديد. ", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095335_user.wav", - "user_duration_sec": 1.92 - }, - { - "timestamp": "20260515_095338", - "started_at": 1778810018.7574284, - "user_text": "لو ات خ ن واحد ه", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095338_user.wav", - "user_duration_sec": 3.36 - }, - { - "timestamp": "20260515_095348", - "started_at": 1778810028.9448812, - "user_text": "", - "robot_text": "هلا والله", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095348_user.wav", - "user_duration_sec": 0.384 - }, - { - "timestamp": "20260515_095349", - "started_at": 1778810029.4919236, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095349_user.wav", - "user_duration_sec": 3.328 - }, - { - "timestamp": "20260515_095355", - "started_at": 1778810035.5984402, - "user_text": "Yes.", - "robot_text": "مرحبا ليام وتي تي، شحالكم؟ عساكم", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095355_user.wav", - "user_duration_sec": 0.256, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095355_robot.wav", - "robot_duration_sec": 2.8 - }, - { - "timestamp": "20260515_095358", - "started_at": 1778810038.7099447, - "user_text": "", - "robot_text": "بخير. مجهود طيب في عملكم في Wisemate.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095358_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095358_robot.wav", - "robot_duration_sec": 3.12 - }, - { - "timestamp": "20260515_095404", - "started_at": 1778810044.0742111, - "user_text": "但 是 把 這個 暴 力 為 一樣 , 那 這 次 兩 次 呢 ,他 就 per fect list , 然後 就 是 發 這樣 的 , 其實就是長這樣。", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095404_user.wav", - "user_duration_sec": 7.424 - }, - { - "timestamp": "20260515_095429", - "started_at": 1778810069.7691848, - "user_text": ".", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095429_user.wav", - "user_duration_sec": 13.696 - }, - { - "timestamp": "20260515_095452", - "started_at": 1778810092.558289, - "user_text": ".", - "robot_text": "مع السلامة ليام، بالتوفيق في عملك!", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095452_user.wav", - "user_duration_sec": 3.808, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095452_robot.wav", - "robot_duration_sec": 2.44 - }, - { - "timestamp": "20260515_095504", - "started_at": 1778810104.3199503, - "user_text": "Мама в новых видео.", - "robot_text": "في أمان الله تي تي، نشوفك على خير.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095504_user.wav", - "user_duration_sec": 2.944, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095504_robot.wav", - "robot_duration_sec": 2.28 - }, - { - "timestamp": "20260515_095516", - "started_at": 1778810116.1252255, - "user_text": "", - "robot_text": "بالعافية تي تي!", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095516_user.wav", - "user_duration_sec": 1.856, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095516_robot.wav", - "robot_duration_sec": 1.04 - }, - { - "timestamp": "20260515_095522", - "started_at": 1778810122.0784693, - "user_text": "ให้ ปลา อีก ล่ะ ครับ", - "robot_text": "تسلمين، أشوفج على خير.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095522_user.wav", - "user_duration_sec": 1.152, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095522_robot.wav", - "robot_duration_sec": 1.16 - }, - { - "timestamp": "20260515_095524", - "started_at": 1778810124.9922345, - "user_text": "", - "robot_text": "مع السلامة تي تي، درب السلامة.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095524_user.wav", - "user_duration_sec": 0.768, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_095524_robot.wav", - "robot_duration_sec": 2.32 - }, - { - "timestamp": "20260515_095528", - "started_at": 1778810128.147848, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095528_user.wav", - "user_duration_sec": 0.992 - }, - { - "timestamp": "20260515_095530", - "started_at": 1778810130.0458086, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095530_user.wav", - "user_duration_sec": 4.48 - }, - { - "timestamp": "20260515_095750", - "started_at": 1778810270.719531, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_095750_user.wav", - "user_duration_sec": 0.192 - }, - { - "timestamp": "20260515_100406", - "started_at": 1778810646.4413834, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_100406_user.wav", - "user_duration_sec": 0.064 - }, - { - "timestamp": "20260515_100407", - "started_at": 1778810647.9195557, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_100407_user.wav", - "user_duration_sec": 0.384 - }, - { - "timestamp": "20260515_100408", - "started_at": 1778810648.6200986, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_100408_user.wav", - "user_duration_sec": 0.832 - }, - { - "timestamp": "20260515_102425", - "started_at": 1778811865.2814493, - "user_text": "", - "robot_text": "مرحبا يا خوي، حالياً الكاميرا عندي مب", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_102425_user.wav", - "user_duration_sec": 3.36, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_102425_robot.wav", - "robot_duration_sec": 2.64 - }, - { - "timestamp": "20260515_102430", - "started_at": 1778811870.6020293, - "user_text": "", - "robot_text": "مفتوحة، والتعرف على الويه بعد مب شغال. يعني ما أقدر أشوف", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_102430_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_102430_robot.wav", - "robot_duration_sec": 4.12 - }, - { - "timestamp": "20260515_102431", - "started_at": 1778811871.3578074, - "user_text": "", - "robot_text": "أي شي أو أتعرف على حد بالفترة الحالية. لو احتجت شي، خبرني.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_102431_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_102431_robot.wav", - "robot_duration_sec": 4.56 - }, - { - "timestamp": "20260515_102435", - "started_at": 1778811875.9227085, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_102435_user.wav", - "user_duration_sec": 1.472 - }, - { - "timestamp": "20260515_102438", - "started_at": 1778811878.6716042, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_102438_user.wav", - "user_duration_sec": 0.224 - }, - { - "timestamp": "20260515_105540", - "started_at": 1778813740.1552172, - "user_text": "", - "robot_text": "مرحبا بك، يا خوي. أنا سند، كيف أقدر أساعدك اليوم؟", - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_105540_robot.wav", - "robot_duration_sec": 4.28 - }, - { - "timestamp": "20260515_105543", - "started_at": 1778813743.2126505, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105543_user.wav", - "user_duration_sec": 0.8 - }, - { - "timestamp": "20260515_105544", - "started_at": 1778813744.2409508, - "user_text": "", - "robot_text": "تمام، يا خوي. الحين صار بإمكاني أتعرف", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105544_user.wav", - "user_duration_sec": 0.864, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_105544_robot.wav", - "robot_duration_sec": 2.92 - }, - { - "timestamp": "20260515_105548", - "started_at": 1778813748.3176389, - "user_text": "", - "robot_text": "على الأشخاص اللي أعرفهم.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105548_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_105548_robot.wav", - "robot_duration_sec": 1.68 - }, - { - "timestamp": "20260515_105553", - "started_at": 1778813753.512742, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105553_user.wav", - "user_duration_sec": 0.288 - }, - { - "timestamp": "20260515_105553", - "started_at": 1778813753.980176, - "user_text": ".", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105553_user.wav", - "user_duration_sec": 1.696 - }, - { - "timestamp": "20260515_105559", - "started_at": 1778813759.8452103, - "user_text": "13 15 15 1", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105559_user.wav", - "user_duration_sec": 0.992 - }, - { - "timestamp": "20260515_105602", - "started_at": 1778813762.5192037, - "user_text": "Şu dur. İşte.", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105602_user.wav", - "user_duration_sec": 0.896 - }, - { - "timestamp": "20260515_105606", - "started_at": 1778813766.116226, - "user_text": ".", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105606_user.wav", - "user_duration_sec": 2.72 - }, - { - "timestamp": "20260515_105610", - "started_at": 1778813770.641564, - "user_text": "", - "robot_text": "هلا والله يا حازم، شحالك؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105610_user.wav", - "user_duration_sec": 1.152, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_105610_robot.wav", - "robot_duration_sec": 2.12 - }, - { - "timestamp": "20260515_105615", - "started_at": 1778813775.3647385, - "user_text": "", - "robot_text": "عساك بخير وموفق", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105615_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_105615_robot.wav", - "robot_duration_sec": 1.12 - }, - { - "timestamp": "20260515_105615", - "started_at": 1778813775.8352149, - "user_text": "對 , 現 在 我 們 的 後", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105615_user.wav", - "user_duration_sec": 3.712 - }, - { - "timestamp": "20260515_105622", - "started_at": 1778813782.676456, - "user_text": "", - "robot_text": "شخبارك يا", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105622_user.wav", - "user_duration_sec": 7.36 - }, - { - "timestamp": "20260515_105635", - "started_at": 1778813795.5579245, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105635_user.wav", - "user_duration_sec": 0.864 - }, - { - "timestamp": "20260515_105637", - "started_at": 1778813797.98301, - "user_text": ". Oh.", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105637_user.wav", - "user_duration_sec": 3.168 - }, - { - "timestamp": "20260515_105643", - "started_at": 1778813803.0237193, - "user_text": "", - "robot_text": "شغال على", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105643_user.wav", - "user_duration_sec": 1.056 - }, - { - "timestamp": "20260515_105647", - "started_at": 1778813807.0194955, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105647_user.wav", - "user_duration_sec": 3.04 - }, - { - "timestamp": "20260515_105652", - "started_at": 1778813812.3309493, - "user_text": ". . . . .", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105652_user.wav", - "user_duration_sec": 1.504 - }, - { - "timestamp": "20260515_105655", - "started_at": 1778813815.1605892, - "user_text": "", - "robot_text": "شو المشاريع الجديدة اللي عندك في شركة ys lootah tech؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105655_user.wav", - "user_duration_sec": 2.4, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_105655_robot.wav", - "robot_duration_sec": 2.52 - }, - { - "timestamp": "20260515_105700", - "started_at": 1778813820.2270343, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105700_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_105700_robot.wav", - "robot_duration_sec": 0.52 - }, - { - "timestamp": "20260515_105701", - "started_at": 1778813821.6776195, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105701_user.wav", - "user_duration_sec": 0.512 - }, - { - "timestamp": "20260515_105702", - "started_at": 1778813822.4117692, - "user_text": "", - "robot_text": "منورنا ليام، شحالك؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105702_user.wav", - "user_duration_sec": 1.92, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_105702_robot.wav", - "robot_duration_sec": 1.96 - }, - { - "timestamp": "20260515_105709", - "started_at": 1778813829.5796034, - "user_text": "Ah, ik maak hem.", - "robot_text": "تسلم، ما تقصر.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105709_user.wav", - "user_duration_sec": 12.512, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_105709_robot.wav", - "robot_duration_sec": 1.52 - }, - { - "timestamp": "20260515_105746", - "started_at": 1778813866.3928776, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105746_user.wav", - "user_duration_sec": 0.16 - }, - { - "timestamp": "20260515_105747", - "started_at": 1778813867.4134119, - "user_text": "มา", - "robot_text": "اعتذر، ما أشوف عدل الحين.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105747_user.wav", - "user_duration_sec": 0.704, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_105747_robot.wav", - "robot_duration_sec": 2.04 - }, - { - "timestamp": "20260515_105756", - "started_at": 1778813876.4170573, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105756_user.wav", - "user_duration_sec": 0.32 - }, - { - "timestamp": "20260515_105756", - "started_at": 1778813876.967098, - "user_text": ".", - "robot_text": "seems like there's something blocking the camera right now.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105756_user.wav", - "user_duration_sec": 0.576, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260515_105756_robot.wav", - "robot_duration_sec": 3.12 - }, - { - "timestamp": "20260515_105802", - "started_at": 1778813882.1034114, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260515_105802_user.wav", - "user_duration_sec": 0.448 - }, - { - "timestamp": "20260516_112009", - "started_at": 1778901609.5694642, - "user_text": "", - "robot_text": "هلا والله يا قسام، شحالك؟ عساك بخير وصحة؟ شو مسوي مهندس الروبوتات؟", - "robot_wav": "/home/unitree/Sanad/data/recordings/20260516_112009_robot.wav", - "robot_duration_sec": 5.56 - }, - { - "timestamp": "20260516_112010", - "started_at": 1778901610.781415, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260516_112010_user.wav", - "user_duration_sec": 0.672 - }, - { - "timestamp": "20260519_082138", - "started_at": 1779150098.714068, - "user_text": "", - "robot_text": "مرحبا يا همام، شحالك؟ عساك بخير يا مدير المشاريع؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_082138_user.wav", - "user_duration_sec": 0.672, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_082138_robot.wav", - "robot_duration_sec": 4.24 - }, - { - "timestamp": "20260519_082148", - "started_at": 1779150108.4636102, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_082148_user.wav", - "user_duration_sec": 4.064 - }, - { - "timestamp": "20260519_082158", - "started_at": 1779150118.6651778, - "user_text": "", - "robot_text": "الكاميرا الحين مسكرة يا خوي، وما أقدر أشوف أي شي. بعد ما بقدر أميز الوجوه وأرحب بالأسماء.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_082158_user.wav", - "user_duration_sec": 0.32, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_082158_robot.wav", - "robot_duration_sec": 6.52 - }, - { - "timestamp": "20260519_084918", - "started_at": 1779151758.8081703, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_084918_user.wav", - "user_duration_sec": 0.064 - }, - { - "timestamp": "20260519_084918", - "started_at": 1779151758.9995167, - "user_text": "", - "robot_text": "مرحبا يا خوي، صباح الخير.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_084918_user.wav", - "user_duration_sec": 1.6, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_084918_robot.wav", - "robot_duration_sec": 1.8 - }, - { - "timestamp": "20260519_084923", - "started_at": 1779151763.0834322, - "user_text": "", - "robot_text": "أنا سند، شتبي مساعده؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_084923_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_084923_robot.wav", - "robot_duration_sec": 2.56 - }, - { - "timestamp": "20260519_084927", - "started_at": 1779151767.0389714, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_084927_user.wav", - "user_duration_sec": 1.472 - }, - { - "timestamp": "20260519_115018", - "started_at": 1779162618.8697824, - "user_text": "", - "robot_text": "Marhaba! Shkhabarak Ana جاهز لأي مساعدة تحتاجها، آمِر يا خوي.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_115018_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_115018_robot.wav", - "robot_duration_sec": 4.48 - }, - { - "timestamp": "20260519_115025", - "started_at": 1779162625.484732, - "user_text": "", - "robot_text": "سموحة يا خوي، في وايد دوشة في الخلفية", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_115025_user.wav", - "user_duration_sec": 5.664, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_115025_robot.wav", - "robot_duration_sec": 2.44 - }, - { - "timestamp": "20260519_115036", - "started_at": 1779162636.2765567, - "user_text": "", - "robot_text": "مب قادر أسمعك زين. ممكن تعيد اللي قلته؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_115036_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_115036_robot.wav", - "robot_duration_sec": 3.36 - }, - { - "timestamp": "20260519_115039", - "started_at": 1779162639.1431997, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_115039_user.wav", - "user_duration_sec": 0.928 - }, - { - "timestamp": "20260519_115041", - "started_at": 1779162641.4765987, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_115041_user.wav", - "user_duration_sec": 3.904 - }, - { - "timestamp": "20260519_115049", - "started_at": 1779162649.8598154, - "user_text": "وال له مح تار الص راح ه اخ ذ", - "robot_text": "تفضل، الحيرة تصير. شو بالضبط محتار فيه؟ يمكن", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_115049_user.wav", - "user_duration_sec": 5.312, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_115049_robot.wav", - "robot_duration_sec": 3.44 - }, - { - "timestamp": "20260519_115102", - "started_at": 1779162662.9256096, - "user_text": "ط يب والله محتار صراحه", - "robot_text": "ايش اللي محيرك بالضبط؟ يمكن أقدر أساعدك بوجهة نظر.", - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_115102_robot.wav", - "robot_duration_sec": 3.28 - }, - { - "timestamp": "20260519_115109", - "started_at": 1779162669.5039554, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_115109_user.wav", - "user_duration_sec": 1.44, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_115109_robot.wav", - "robot_duration_sec": 0.56 - }, - { - "timestamp": "20260519_115113", - "started_at": 1779162673.303498, - "user_text": "اه صدقوني", - "robot_text": "أكيد، قول اسمعك.", - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_115113_robot.wav", - "robot_duration_sec": 1.56 - }, - { - "timestamp": "20260519_115125", - "started_at": 1779162685.7355008, - "user_text": "", - "robot_text": "زين، خذ راحتك.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_115125_user.wav", - "user_duration_sec": 1.344, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_115125_robot.wav", - "robot_duration_sec": 1.28 - }, - { - "timestamp": "20260519_115131", - "started_at": 1779162691.091815, - "user_text": ".", - "robot_text": "تمام، أنا في انتظارك.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_115131_user.wav", - "user_duration_sec": 0.256, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_115131_robot.wav", - "robot_duration_sec": 1.68 - }, - { - "timestamp": "20260519_115138", - "started_at": 1779162698.0706642, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_115138_user.wav", - "user_duration_sec": 0.128 - }, - { - "timestamp": "20260519_130234", - "started_at": 1779166954.0533257, - "user_text": "", - "robot_text": "مرحبا بك يا خوي. أنا جاهز لأي مساعدة تحتاجها.", - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_130234_robot.wav", - "robot_duration_sec": 3.96 - }, - { - "timestamp": "20260519_130239", - "started_at": 1779166959.1727755, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_130239_user.wav", - "user_duration_sec": 1.44 - }, - { - "timestamp": "20260519_154619", - "started_at": 1779176779.8765137, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154619_user.wav", - "user_duration_sec": 0.256 - }, - { - "timestamp": "20260519_154620", - "started_at": 1779176780.6527424, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154620_user.wav", - "user_duration_sec": 0.512 - }, - { - "timestamp": "20260519_154621", - "started_at": 1779176781.9523358, - "user_text": "", - "robot_text": "هلا، كمل يا خوي.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154621_user.wav", - "user_duration_sec": 2.24, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_154621_robot.wav", - "robot_duration_sec": 1.0 - }, - { - "timestamp": "20260519_154632", - "started_at": 1779176792.8424387, - "user_text": "", - "robot_text": "أسمعك، تفضل", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154632_user.wav", - "user_duration_sec": 0.448, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_154632_robot.wav", - "robot_duration_sec": 1.08 - }, - { - "timestamp": "20260519_154633", - "started_at": 1779176793.7084265, - "user_text": "مرحبا بك", - "robot_text": "مراحب، شخبارك؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154633_user.wav", - "user_duration_sec": 0.096, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_154633_robot.wav", - "robot_duration_sec": 1.36 - }, - { - "timestamp": "20260519_154636", - "started_at": 1779176796.5443742, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154636_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_154636_robot.wav", - "robot_duration_sec": 0.24 - }, - { - "timestamp": "20260519_154640", - "started_at": 1779176800.3990128, - "user_text": "", - "robot_text": "تمام والله، أنت شعلومك؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154640_user.wav", - "user_duration_sec": 8.192, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_154640_robot.wav", - "robot_duration_sec": 2.0 - }, - { - "timestamp": "20260519_154703", - "started_at": 1779176823.6258621, - "user_text": "كيفك الشيء عرفتي؟", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154703_user.wav", - "user_duration_sec": 0.48 - }, - { - "timestamp": "20260519_154705", - "started_at": 1779176825.3986928, - "user_text": "كيفك السلام عليكم؟", - "robot_text": "بخير ونعمة، الحمد لله. في شي تباني أساعدك فيه؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154705_user.wav", - "user_duration_sec": 0.48, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_154705_robot.wav", - "robot_duration_sec": 3.92 - }, - { - "timestamp": "20260519_154711", - "started_at": 1779176831.2435904, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154711_user.wav", - "user_duration_sec": 0.672 - }, - { - "timestamp": "20260519_154712", - "started_at": 1779176832.118435, - "user_text": "Çok para.", - "robot_text": "كل خير وبركة. علومك أنت؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154712_user.wav", - "user_duration_sec": 1.248, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_154712_robot.wav", - "robot_duration_sec": 2.4 - }, - { - "timestamp": "20260519_154720", - "started_at": 1779176840.4289267, - "user_text": "الحمد لل ه ت قدر تنفع ايدك", - "robot_text": "يا خوي، أنا ما عندي أيادي", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154720_user.wav", - "user_duration_sec": 2.24, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_154720_robot.wav", - "robot_duration_sec": 1.6 - }, - { - "timestamp": "20260519_154727", - "started_at": 1779176847.6233058, - "user_text": "", - "robot_text": "أرفعها، بس أقدر أساعدك بأي شي ثاني تحتاجه.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154727_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_154727_robot.wav", - "robot_duration_sec": 4.32 - }, - { - "timestamp": "20260519_154733", - "started_at": 1779176853.197034, - "user_text": "سا عدني شو مثلا", - "robot_text": "أكيد، مثلاً تحتاج", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154733_user.wav", - "user_duration_sec": 1.408, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_154733_robot.wav", - "robot_duration_sec": 1.52 - }, - { - "timestamp": "20260519_154737", - "started_at": 1779176857.9814293, - "user_text": "", - "robot_text": "معلومات عن شي", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154737_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_154737_robot.wav", - "robot_duration_sec": 1.16 - }, - { - "timestamp": "20260519_154740", - "started_at": 1779176860.114877, - "user_text": "", - "robot_text": "معين؟ أو ترجمة لشيء؟ أو حتى سوالف عادية. آمرني.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154740_user.wav", - "user_duration_sec": 0.224, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_154740_robot.wav", - "robot_duration_sec": 6.08 - }, - { - "timestamp": "20260519_154742", - "started_at": 1779176862.9782894, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154742_user.wav", - "user_duration_sec": 1.472 - }, - { - "timestamp": "20260519_154752", - "started_at": 1779176872.1843014, - "user_text": "كم كم عد د السكان في الامارات", - "robot_text": "تعداد السكان في الإمارات يتغير باستمرار، بس بشكل عام يقارب 10 مليون نسمة. تبغي معلومات أكثر عن أي إمارة محددة؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154752_user.wav", - "user_duration_sec": 0.832, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_154752_robot.wav", - "robot_duration_sec": 9.72 - }, - { - "timestamp": "20260519_154804", - "started_at": 1779176884.876378, - "user_text": "من هو اش هر بر ند محاشي لبنه؟", - "robot_text": "بصراحة، ما عندي معلومات دقيقة عن أشهر براند محاشي لبن، لأن هالشيء يختلف حسب المنطقة والأذواق. بس إذا تدور على توصيات، ممكن تحصل بالكثير في تطبيقات التوصيل أو مواقع التقييم.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154804_user.wav", - "user_duration_sec": 1.952, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_154804_robot.wav", - "robot_duration_sec": 14.84 - }, - { - "timestamp": "20260519_154832", - "started_at": 1779176912.7413862, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154832_user.wav", - "user_duration_sec": 0.544 - }, - { - "timestamp": "20260519_154833", - "started_at": 1779176913.5135, - "user_text": "تعرف خلطات الفريد", - "robot_text": "هلا، الحين أشوفك زين. بالنسبة لـ \"خلطات الفريد\"، ما عندي فكرة واضحة عنها، ممكن توضح لي أكثر عنها؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154833_user.wav", - "user_duration_sec": 1.056, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_154833_robot.wav", - "robot_duration_sec": 8.32 - }, - { - "timestamp": "20260519_154851", - "started_at": 1779176931.2157605, - "user_text": "تعال ندخل يلا امشي", - "robot_text": "أنا ما", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154851_user.wav", - "user_duration_sec": 4.384 - }, - { - "timestamp": "20260519_154903", - "started_at": 1779176943.9759922, - "user_text": "ياخد القرار اللي شفنا", - "robot_text": "يا خوي، أنا ما أقدر أمشي ولا أتخذ قرارات بنفسي. أنا بس مساعد افتراضي. أقدر أجاوب على أسئلتك أو أقدم لك معلومات.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154903_user.wav", - "user_duration_sec": 1.952, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_154903_robot.wav", - "robot_duration_sec": 10.28 - }, - { - "timestamp": "20260519_154918", - "started_at": 1779176958.5768716, - "user_text": "يعني", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154918_user.wav", - "user_duration_sec": 13.984 - }, - { - "timestamp": "20260519_154949", - "started_at": 1779176989.62968, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154949_user.wav", - "user_duration_sec": 1.248 - }, - { - "timestamp": "20260519_154955", - "started_at": 1779176995.5653973, - "user_text": "ก็ เลย นะ คะ", - "robot_text": "صحيح، ما أقدر أتصرف من تلقاء نفسي. أنا بس أستجيب للي تقوله. في شي معين في بالك؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_154955_user.wav", - "user_duration_sec": 0.864, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_154955_robot.wav", - "robot_duration_sec": 7.16 - }, - { - "timestamp": "20260519_155008", - "started_at": 1779177008.4833374, - "user_text": "", - "robot_text": "كاميرا وقفت، ما أقدر أشوفك الحين.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_155008_user.wav", - "user_duration_sec": 1.184, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_155008_robot.wav", - "robot_duration_sec": 2.48 - }, - { - "timestamp": "20260519_155015", - "started_at": 1779177015.6755085, - "user_text": "", - "robot_text": "تأمرني أمر،", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_155015_user.wav", - "user_duration_sec": 5.536, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_155015_robot.wav", - "robot_duration_sec": 0.96 - }, - { - "timestamp": "20260519_155026", - "started_at": 1779177026.3300655, - "user_text": "", - "robot_text": "شو اللي تباني أساعدك فيه؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_155026_user.wav", - "user_duration_sec": 0.032, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_155026_robot.wav", - "robot_duration_sec": 2.16 - }, - { - "timestamp": "20260519_155029", - "started_at": 1779177029.3373148, - "user_text": "", - "robot_text": "تمام، شو الموضوع اللي حاب نتكلم عنه؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_155029_user.wav", - "user_duration_sec": 12.96, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_155029_robot.wav", - "robot_duration_sec": 2.56 - }, - { - "timestamp": "20260519_155101", - "started_at": 1779177061.0948687, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_155101_user.wav", - "user_duration_sec": 39.68 - }, - { - "timestamp": "20260519_155251", - "started_at": 1779177171.9114149, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_155251_user.wav", - "user_duration_sec": 4.416 - }, - { - "timestamp": "20260519_155317", - "started_at": 1779177197.1195416, - "user_text": "", - "robot_text": "مرحبا يا خوي، مفهوم. بتذكر هالحالة وبتعامل على أساسها.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_155317_user.wav", - "user_duration_sec": 1.12, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_155317_robot.wav", - "robot_duration_sec": 4.24 - }, - { - "timestamp": "20260519_155328", - "started_at": 1779177208.8839252, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_155328_user.wav", - "user_duration_sec": 40.064 - }, - { - "timestamp": "20260519_155616", - "started_at": 1779177376.066496, - "user_text": "", - "robot_text": "آمرني يا خوي، كيف أقدر أساعدك؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_155616_user.wav", - "user_duration_sec": 3.392, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_155616_robot.wav", - "robot_duration_sec": 2.32 - }, - { - "timestamp": "20260519_155622", - "started_at": 1779177382.911812, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_155622_user.wav", - "user_duration_sec": 0.576 - }, - { - "timestamp": "20260519_155625", - "started_at": 1779177385.28559, - "user_text": ".", - "robot_text": "الصوت مب واضح بالمرة، ممكن", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_155625_user.wav", - "user_duration_sec": 7.2, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_155625_robot.wav", - "robot_duration_sec": 1.56 - }, - { - "timestamp": "20260519_155641", - "started_at": 1779177401.9648745, - "user_text": "", - "robot_text": "تعيد اللي قلته؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260519_155641_user.wav", - "user_duration_sec": 2.912, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260519_155641_robot.wav", - "robot_duration_sec": 1.52 - }, - { - "timestamp": "20260520_113905", - "started_at": 1779248345.1616135, - "user_text": "", - "robot_text": "مرحبا يا خوي. وصلتني معلومات الحالة. أي شي تباه بخصوص الكاميرا أو التعرف على الويه، أنا حاضر، بس خبرني أفعّلهم من لوحة التحكم.", - "robot_wav": "/home/unitree/Sanad/data/recordings/20260520_113905_robot.wav", - "robot_duration_sec": 8.92 - }, - { - "timestamp": "20260520_113914", - "started_at": 1779248354.961822, - "user_text": "", - "robot_text": "هلا بك. كل شي تمام؟ في شي معين تبغي تتكلم عنه؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260520_113914_user.wav", - "user_duration_sec": 8.512, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260520_113914_robot.wav", - "robot_duration_sec": 3.92 - }, - { - "timestamp": "20260520_121411", - "started_at": 1779250451.8024144, - "user_text": "", - "robot_text": "", - "user_wav": "/home/unitree/Sanad/data/recordings/20260520_121411_user.wav", - "user_duration_sec": 0.544 - }, - { - "timestamp": "20260520_121412", - "started_at": 1779250452.6009102, - "user_text": "I'm recording though.", - "robot_text": "مرحبا يا خوي، زين انك تسجل. شو السالفة؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260520_121412_user.wav", - "user_duration_sec": 0.896, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260520_121412_robot.wav", - "robot_duration_sec": 3.56 - }, - { - "timestamp": "20260528_164730", - "started_at": 1779958050.2168624, - "user_text": "", - "robot_text": "مرحبا، شخبارك", - "robot_wav": "/home/unitree/Sanad/data/recordings/20260528_164730_robot.wav", - "robot_duration_sec": 1.48 - }, - { - "timestamp": "20260528_164732", - "started_at": 1779958052.8729692, - "user_text": "Carte per guadagnare.", - "robot_text": "\"Carte per guadagnare\"? ما فهمت صراحة، ممكن توضح أكثر يا خوي؟", - "user_wav": "/home/unitree/Sanad/data/recordings/20260528_164732_user.wav", - "user_duration_sec": 0.96, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260528_164732_robot.wav", - "robot_duration_sec": 4.8 - }, - { - "timestamp": "20260528_174103", - "started_at": 1779961263.768733, - "user_text": "", - "robot_text": "مرحبا يا خوي، وصلتني المعلومات. أنا جاهز أساعدك بأي شيء تحتاجه.", - "user_wav": "/home/unitree/Sanad/data/recordings/20260528_174103_user.wav", - "user_duration_sec": 0.064, - "robot_wav": "/home/unitree/Sanad/data/recordings/20260528_174103_robot.wav", - "robot_duration_sec": 4.72 - } - ], - "total_records": 429 + "records": [], + "total_records": 0 } \ No newline at end of file diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000..7adfdfb --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,13 @@ +# Copy to `.env` (gitignored) and fill in. Loaded at run time via --env-file / +# docker-compose env_file — nothing here is ever baked into the image. +# +# install.sh writes this file for you (key prompt + auto-detected interface); +# you only need to edit it by hand for a manual `run.sh` install. + +# The Gemini API key — REQUIRED. +SANAD_GEMINI_API_KEY= + +# Robot DDS interface (the NIC on the R1 internal 192.168.123.x net). +# OPTIONAL — leave unset to let install.sh / run.sh auto-detect it (falls back +# to eth10, the standard EDU-backpack name). Pin it only if your robot differs. +# SANAD_DDS_INTERFACE=eth10 diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..5015e65 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,95 @@ +# SanadR1 — Gemini voice + dashboard for the Unitree R1 EDU backpack. +# +# BUILD THIS ON THE BACKPACK (native aarch64/arm64). Building on an x86 host +# needs `docker buildx` + qemu. Needs internet at build time (CycloneDDS clone, +# pip). Run with `--network host` (see docker-compose.yml) — the robot's DDS and +# the on-board mic multicast (239.168.123.161:5555) do not traverse a NAT bridge. +# Preferred launch: docker/build_and_run.sh (it also disables the old conda service). +FROM arm64v8/python:3.10-slim-bookworm + +# ── System deps ────────────────────────────────────────────────────────────── +# build-essential/cmake → compile the CycloneDDS *python binding* (no +# aarch64 wheel) + PyAudio if it falls back to sdist +# cyclonedds-dev/-tools → PREBUILT CycloneDDS 0.10.2 C lib + idlc for arm64 +# (Debian bookworm ships 0.10.2-2 — exact robot +# match; avoids the source compile that segfaults +# under qemu cross-build and is slow even native) +# portaudio19-dev/libasound2 → PyAudio must import (AudioManager needs it even +# with no sound cards; audio itself is DDS/UDP) +# iproute2 → REQUIRED: BuiltinMic._find_g1_local_ip() shells +# `ip -4 -o addr` unguarded; missing `ip` = no mic +# libgomp1 → provides libgomp.so.1 for the LD_PRELOAD below +# curl → HEALTHCHECK +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential cmake \ + cyclonedds-dev cyclonedds-tools \ + portaudio19-dev libasound2 iproute2 libgomp1 \ + ffmpeg \ + pulseaudio pulseaudio-utils alsa-utils \ + curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# aarch64 "cannot allocate memory in static TLS block" guard on numpy/opencv +# import (start_sanad.sh calls this a mandatory Jetson fix). Soname form is +# resolved via ldconfig, so it can't point at a missing path (libgomp1 above). +ENV LD_PRELOAD=libgomp.so.1 + +# cyclonedds' find helper (buildhelp/cyclone_search.py) hardcodes +# $CYCLONEDDS_HOME/lib/libddsc.so, but Debian installs into the multiarch dir +# /usr/lib/aarch64-linux-gnu. Symlink the two libs it checks into /usr/lib so +# good_directory(/usr) passes. (Runtime loading uses the multiarch path already +# on the default loader search, so no LD_LIBRARY_PATH is needed.) +ENV CYCLONEDDS_HOME=/usr +RUN ln -sf /usr/lib/aarch64-linux-gnu/libddsc.so /usr/lib/libddsc.so \ + && ln -sf /usr/lib/aarch64-linux-gnu/libcycloneddsidl.so /usr/lib/libcycloneddsidl.so + +# ── Python dependencies (mask removed → no bleak) ──────────────────────────── +COPY docker/requirements.docker.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt +# CycloneDDS python binding, compiled against the C lib above (no aarch64 wheel). +# cyclonedds 0.10.2's build helper imports wheel.bdist_wheel (removed in +# wheel>=0.46), so pin the build toolchain before the --no-build-isolation build. +RUN pip install --no-cache-dir "setuptools<80" "wheel<0.46" \ + && pip install --no-cache-dir --no-build-isolation cyclonedds==0.10.2 + +# ── Unitree SDK (vendored wheel — not on PyPI) + native crc lib (wheel omits it) +COPY docker/vendor/unitree_sdk2py-*.whl /tmp/ +RUN pip install --no-cache-dir --no-deps /tmp/unitree_sdk2py-*.whl +COPY docker/vendor/crc_aarch64.so docker/vendor/crc_amd64.so \ + /usr/local/lib/python3.10/site-packages/unitree_sdk2py/utils/lib/ + +# ── Application code ───────────────────────────────────────────────────────── +# main.py's deployed-layout shim imports THIS folder by its own name and aliases +# Project.Sanad → it, inserting the folder's PARENT (/opt) into sys.path. What +# matters: a non-'Project' parent + an __init__.py (both hold here). The name +# 'SanadR1' is what the running instance is called, so keep it for clarity. +WORKDIR /opt +COPY . /opt/SanadR1 +WORKDIR /opt/SanadR1 + +# Non-root app user for USB audio: Sanad refuses to touch audio as root, and +# PulseAudio needs a per-user session. uid 1000 mirrors the host 'unitree'; put it +# in the 'audio' group (Debian gid 29 == the host's) so it can reach /dev/snd. +# The entrypoint (still root) chowns the mounted volumes, then drops to this user. +RUN groupmod -g 29 audio 2>/dev/null || groupadd -g 29 audio ; \ + useradd -m -u 1000 -o -s /bin/bash sanad \ + && usermod -aG audio sanad \ + && mkdir -p /run/user/1000 && chown sanad:sanad /run/user/1000 && chmod 700 /run/user/1000 \ + && chown -R sanad:sanad /opt/SanadR1 + +# ── Runtime config (audio + DDS are network-based; no local sound hardware) ── +ENV PYTHONUNBUFFERED=1 \ + SANAD_DDS_INTERFACE=eth10 \ + SANAD_DASHBOARD_HOST=0.0.0.0 \ + SANAD_NAV_TOOLS=0 \ + SANAD_AUDIO_PROFILE=builtin \ + SANAD_VOICE_BRAIN=gemini \ + PORT=8001 +# SANAD_GEMINI_API_KEY is supplied at run time (compose env_file / -e), never baked. + +EXPOSE 8001 +HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \ + CMD curl -fsS "http://127.0.0.1:${PORT:-8001}/" >/dev/null || exit 1 + +# entrypoint waits for eth10 (robot link) before launching, mirroring start_sanad.sh +ENTRYPOINT ["/opt/SanadR1/docker/entrypoint.sh"] diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..e3df27e --- /dev/null +++ b/docker/README.md @@ -0,0 +1,85 @@ +# SanadR1 in Docker (R1 EDU backpack) + +Containerized SanadR1 — Gemini voice + dashboard — for the Unitree R1 backpack +(Jetson Orin NX, aarch64). The BLE LED-mask is removed from this build. + +## Why these choices +- **`network_mode: host`** — the robot's CycloneDDS discovery and the on-board + mic UDP multicast (`239.168.123.161:5555`) do not cross a Docker bridge NAT, + and the dashboard must bind the host's `eth10` / `wlan0`. Host networking is + the standard pattern for Unitree/ROS DDS. +- **CycloneDDS built from source** in the image — there is no aarch64 PyPI wheel + for `cyclonedds==0.10.2`; it must compile against the C library (as on the + host conda env). +- **`unitree_sdk2py` vendored** (`docker/vendor/…whl`) — not on PyPI — plus the + native `crc_aarch64.so` copied in by hand (the wheel omits `utils/lib/`). +- **Named volumes** (`data`, `config`, `logs`) auto-populate their defaults from + the image on first run, then persist the dashboard's writes across rebuilds. + +## Install on ANY R1 (offline bundle — recommended) +No source, registry, or internet needed on the robot — ship one tarball. + +**1. Build the bundle (once, on the workstation):** +```bash +cd Project/R1/SanadR1/docker +./make_bundle.sh # reuses the current sanadr1:latest image +# or: ./make_bundle.sh --build # cross-build the arm64 image first +# → /tmp/sanadr1-bundle-.tar.gz (image + install.sh + run.sh + docs) +``` + +**2. Install on any R1 backpack:** +```bash +scp /tmp/sanadr1-bundle-*.tar.gz r1:~/ +ssh r1 'tar xzf sanadr1-bundle-*.tar.gz && cd sanadr1-bundle && ./install.sh' +``` +`install.sh` auto-detects the robot NIC (the `192.168.123.x` link), the current +user/home, and USB audio; loads the image; writes `.env` (prompts for the Gemini +key, or reads `$SANAD_GEMINI_API_KEY`); installs + enables the systemd auto-start +unit; starts the container and verifies `:8001`. Nothing is hardcoded to one +robot, and re-running it just updates the image. Non-interactive: +```bash +SANAD_GEMINI_API_KEY=AQ... ./install.sh +``` +Target requirements: Docker Engine installed + running, arm64/aarch64. + +## Build + run (ON THE BACKPACK — native aarch64, dev only) +```bash +cd ~/SanadR1/docker +cp .env.example .env # then set SANAD_GEMINI_API_KEY +./build_and_run.sh # builds, disables the old conda service, starts +``` +First build is slow (CycloneDDS compiles). Dashboard: `http://:8001` +(e.g. the WiFi IP `10.255.254.82`, or `192.168.123.164` over the cable). + +## Operate +```bash +docker compose logs -f # live logs +docker compose restart # restart +docker compose down # stop + remove container (volumes persist) +docker compose up -d --build # rebuild after a code change +``` +Auto-start on boot is handled by `restart: unless-stopped` + the enabled +`docker.service` — no systemd unit of our own is needed. + +## Runtime facts +- Robot link: `eth10` (cable) for DDS. Internet + dashboard access: `wlan0`. +- Movement stays disarmed (`SANAD_NAV_TOOLS=0`; loco FSM fixes not yet applied). +- The old conda path (`~/start_sanad_r1.sh` + `sanadr1.service`) is superseded. + **Launch only via `build_and_run.sh`** — it disables `sanadr1.service` first; + a bare `docker compose up` would fight it for port 8001 and crash-loop. +- The container's `entrypoint.sh` waits up to 25 s for `eth10` to be UP with its + `192.168.123.x` IP before starting, so DDS binds the right interface on a cold + robot boot (mirrors the old `start_sanad.sh` wait). +- `LD_PRELOAD=libgomp.so.1` is set to avoid the aarch64 static-TLS import error on + numpy/opencv (a Jetson quirk carried over from the conda launcher). + +## Verify (do NOT trust the healthcheck alone) +The HTTP healthcheck only proves the dashboard bound :8001 — it passes even if the +robot link/voice is dead. After `build_and_run.sh`, confirm the real paths: +```bash +docker compose logs | grep -Ei "eth10 ready|DDS|Action registry|startup complete" +# then speak to it and watch for a Gemini turn in the logs +``` +If you change baked defaults under `config/` or `data/`, an already-initialized +`sanadr1_data` volume keeps the old copy — `docker compose down -v` (or +`docker volume rm sanadr1_data`) to reseed from the new image. diff --git a/docker/build_and_run.sh b/docker/build_and_run.sh new file mode 100755 index 0000000..f86544e --- /dev/null +++ b/docker/build_and_run.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# One-shot build + run of SanadR1 in Docker on the R1 backpack. +# Also disables the old conda systemd service so :8001 doesn't collide. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$HERE" + +if [ ! -f .env ]; then + echo "!! docker/.env missing — copy .env.example to .env and set SANAD_GEMINI_API_KEY" >&2 + exit 1 +fi + +# Old conda-based service (if present) would fight for port 8001. +if systemctl list-unit-files 2>/dev/null | grep -q '^sanadr1\.service'; then + echo ">> stopping + disabling the old conda sanadr1.service" + sudo systemctl disable --now sanadr1.service || true +fi + +echo ">> building image (CycloneDDS compiles from source — first build is slow)" +docker compose build + +echo ">> starting container (host network, auto-restart)" +docker compose up -d + +echo ">> waiting for the dashboard..." +for i in $(seq 1 30); do + if curl -fsS http://127.0.0.1:8001/ >/dev/null 2>&1; then + echo ">> UP: dashboard on :8001" + docker compose ps + exit 0 + fi + sleep 2 +done +echo "!! dashboard did not answer in 60s — check: docker compose logs -f" >&2 +docker compose logs --tail=40 +exit 1 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..3bea0b8 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,37 @@ +# SanadR1 on the R1 EDU backpack. +# +# cd ~/SanadR1/docker +# cp .env.example .env # then put your SANAD_GEMINI_API_KEY in it +# docker compose up -d --build +# +# `network_mode: host` is REQUIRED: the robot's CycloneDDS discovery + the +# on-board mic UDP multicast (239.168.123.161:5555) do not cross a bridge NAT, +# and the dashboard must bind the host's eth10 / wlan0 addresses. +services: + sanadr1: + build: + context: .. # the SanadR1 folder (parent of docker/) + dockerfile: docker/Dockerfile + image: sanadr1:latest + container_name: sanadr1 + network_mode: host # DDS + audio multicast + bind wlan0/eth10 + restart: unless-stopped # auto-starts on boot (docker.service enabled) + env_file: + - .env # SANAD_GEMINI_API_KEY=... + environment: + - SANAD_DDS_INTERFACE=eth10 + - SANAD_DASHBOARD_HOST=0.0.0.0 + - SANAD_NAV_TOOLS=0 + - SANAD_AUDIO_PROFILE=builtin + - SANAD_VOICE_BRAIN=gemini + volumes: + # Named volumes auto-populate from the image on first run, then persist + # the dashboard's writes (recordings, faces, zones, tuned runtime config + # which lives under data/motions/config.json). config/ is left tracking the + # image (read-only defaults) so `up --build` can ship updated defaults. + - sanadr1_data:/opt/SanadR1/data + - sanadr1_logs:/opt/SanadR1/logs + +volumes: + sanadr1_data: + sanadr1_logs: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..a7cf8c0 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Container entrypoint for SanadR1. Waits for the robot link (eth10) to be UP with +# its 192.168.123.x address before launching, so the single per-process DDS init +# (arm.init → ChannelFactoryInitialize) binds the right interface. Mirrors the +# conda start_sanad.sh eth10 wait; without it, on a cold robot boot the process +# would init DDS on a not-yet-ready iface and silently run hardware-dead. +set -u + +IFACE="${SANAD_DDS_INTERFACE:-eth10}" +PORT="${PORT:-8001}" + +echo "[entrypoint] waiting up to 25s for ${IFACE} to be UP with a 192.168.123.x IP…" +for i in $(seq 1 25); do + if ip link show "$IFACE" 2>/dev/null | grep -q "state UP" \ + && ip -4 -o addr show "$IFACE" 2>/dev/null | grep -q "192.168.123."; then + echo "[entrypoint] ${IFACE} ready: $(ip -4 -o addr show "$IFACE" | awk '{print $4}')" + break + fi + sleep 1 +done +if ! ip link show "$IFACE" 2>/dev/null | grep -q "state UP"; then + echo "[entrypoint] WARNING: ${IFACE} not UP after 25s — launching anyway; robot DDS (arm/loco/audio) may be unavailable this session" >&2 +fi + +# ── audio path ─────────────────────────────────────────────────────────────── +# If a USB audio device was passed in (--device /dev/snd), run as the non-root +# 'sanad' user with a per-user PulseAudio so the JBL/Anker-style device profiles +# work (Sanad refuses to touch audio as root). Otherwise keep the original root +# path — the R1's DDS chest speaker + UDP mic need no local sound stack. +if [ -e /dev/snd ]; then + export XDG_RUNTIME_DIR=/run/user/1000 + # Volumes mount root-owned; hand them + the runtime dir to the app user. + chown -R sanad:sanad /opt/SanadR1/data /opt/SanadR1/logs "$XDG_RUNTIME_DIR" 2>/dev/null || true + [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id 2>/dev/null || true + echo "[entrypoint] /dev/snd present — starting PulseAudio (USB audio mode)" + setpriv --reuid sanad --regid sanad --init-groups \ + env XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR" HOME=/home/sanad \ + pulseaudio --start --exit-idle-time=-1 >/dev/null 2>&1 \ + || echo "[entrypoint] WARNING: PulseAudio failed to start" >&2 + # udev doesn't run in the container, so PulseAudio's udev-detect finds no + # cards. Load each USB ALSA card BY NAME (skip the Jetson built-in HDA/APE) so + # its sink/source are named for the device — that's what the Anker/JBL-style + # device profiles match on (e.g. alsa_output.PowerConf.analog-stereo). + sleep 1 + for _card in $(aplay -l 2>/dev/null | sed -n 's/^card [0-9][0-9]*: \([A-Za-z0-9_]*\) .*/\1/p' | sort -u); do + case "$_card" in + HDA|APE|"") : ;; + *) echo "[entrypoint] loading USB audio card: ${_card}" + setpriv --reuid sanad --regid sanad --init-groups \ + env XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR" \ + pactl load-module module-alsa-card device_id="${_card}" >/dev/null 2>&1 || true ;; + esac + done + echo "[entrypoint] launching main.py --port ${PORT} as user 'sanad'" + exec setpriv --reuid sanad --regid sanad --init-groups \ + env XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR" HOME=/home/sanad \ + python3 main.py --port "${PORT}" +else + echo "[entrypoint] no /dev/snd — DDS audio only; launching main.py --port ${PORT} as root" + exec python3 main.py --port "${PORT}" +fi diff --git a/docker/healthcheck.sh b/docker/healthcheck.sh new file mode 100755 index 0000000..f3d0500 --- /dev/null +++ b/docker/healthcheck.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# SanadR1 post-install health check — run ON the R1 backpack after install.sh. +# +# ./healthcheck.sh # checks the local container on :8001 +# ./healthcheck.sh http://HOST:8001 # check a remote backpack's dashboard +# +# Verifies the whole stack end-to-end: Docker container + image + systemd, +# the dashboard, the robot DDS link (all 18 subsystems + fresh lowstate), +# Gemini voice, the camera pipeline, and audio. Exits 0 only if every CRITICAL +# check passes (WARN is allowed); exits 1 on any FAIL so it can gate CI / a +# deploy script. +# ───────────────────────────────────────────────────────────────────────────── +set -uo pipefail +BASE="${1:-http://127.0.0.1:8001}" +LOCAL=1; case "$BASE" in http://127.0.0.1:*|http://localhost:*) LOCAL=1;; *) LOCAL=0;; esac + +PASS=0; WARN=0; FAIL=0 +g(){ printf '\033[0;32m[ PASS ]\033[0m %-34s %s\n' "$1" "${2:-}"; PASS=$((PASS+1)); } +w(){ printf '\033[0;33m[ WARN ]\033[0m %-34s %s\n' "$1" "${2:-}"; WARN=$((WARN+1)); } +b(){ printf '\033[0;31m[ FAIL ]\033[0m %-34s %s\n' "$1" "${2:-}"; FAIL=$((FAIL+1)); } +sec(){ printf '\n\033[1;36m── %s ──\033[0m\n' "$1"; } + +http(){ curl -s -m 6 -o /dev/null -w '%{http_code}' "$1" 2>/dev/null; } +# jget — prints value or empty on error +jget(){ python3 -c "import sys,json +try: + d=json.load(open('$1')) + v=($2) + print('' if v is None else v) +except Exception: + print('')" 2>/dev/null; } + +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +INFO="$TMP/info.json"; VOICE="$TMP/voice.json"; CAM="$TMP/cam.json" + +echo "SanadR1 health check → $BASE ($(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo now))" + +# ── 1. Docker / image / container / systemd (local only) ──────────────────── +if [ "$LOCAL" = 1 ]; then + sec "Container & host" + if command -v docker >/dev/null && docker info >/dev/null 2>&1; then g "docker daemon" "reachable" + else b "docker daemon" "not reachable"; fi + docker image inspect sanadr1:latest >/dev/null 2>&1 && g "image sanadr1:latest" "present" || b "image sanadr1:latest" "missing" + st="$(docker inspect -f '{{.State.Status}}' sanadr1 2>/dev/null)" + [ "$st" = "running" ] && g "container sanadr1" "running" || b "container sanadr1" "state=${st:-absent}" + # container image must match the loaded tag (catches a stale container after an image reload) + cimg="$(docker inspect -f '{{.Image}}' sanadr1 2>/dev/null)"; limg="$(docker image inspect -f '{{.Id}}' sanadr1:latest 2>/dev/null)" + if [ -n "$cimg" ] && [ "$cimg" = "$limg" ]; then g "image up-to-date" "container matches sanadr1:latest" + else w "image up-to-date" "container != sanadr1:latest — re-run run.sh to recreate"; fi + hs="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' sanadr1 2>/dev/null)" + case "$hs" in + healthy) g "container health" "healthy";; + starting) w "container health" "still starting (grace period)";; + unhealthy) b "container health" "unhealthy";; + none) w "container health" "no healthcheck defined";; + *) b "container health" "${hs:-unknown}";; + esac + en="$(systemctl is-enabled sanadr1 2>/dev/null)"; [ "$en" = "enabled" ] && g "systemd enabled" "auto-starts on boot" || w "systemd enabled" "is-enabled=$en" + ac="$(systemctl is-active sanadr1 2>/dev/null)"; [ "$ac" = "active" ] && g "systemd active" "" || w "systemd active" "is-active=$ac" + rp="$(docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' sanadr1 2>/dev/null)"; rp_ok=0; case "$rp" in unless-stopped|always) rp_ok=1;; esac + [ "$rp_ok" = 1 ] && g "restart policy" "$rp" || w "restart policy" "${rp:-none}" + # reboot survival = systemd-enabled OR docker restart policy; both absent → dead after power-cycle + { [ "$en" = "enabled" ] || [ "$rp_ok" = 1 ]; } && g "reboot survival" "auto-starts on power-cycle" \ + || b "reboot survival" "no systemd enable AND no restart policy — will NOT auto-start after power-cycle" + for v in sanadr1_data sanadr1_logs; do docker volume inspect "$v" >/dev/null 2>&1 && g "volume $v" "mounted" || b "volume $v" "missing"; done + # disk headroom on the docker root (a full data/logs volume fails writes while the API still looks healthy) + root="$(docker info -f '{{.DockerRootDir}}' 2>/dev/null || echo /var/lib/docker)"; use="$(df -P "$root" 2>/dev/null | awk 'NR==2{print $5+0}')" + if [ -n "$use" ]; then + if [ "$use" -lt 90 ]; then g "disk space" "${use}% used on $root" + elif [ "$use" -lt 97 ]; then w "disk space" "${use}% used — volumes may soon fail writes" + else b "disk space" "${use}% used — writes will fail"; fi + fi + # clock sanity — the Orin has no RTC; a wrong system clock breaks Gemini's TLS handshake + yr="$(date +%Y 2>/dev/null)" + if [ "${yr:-0}" -ge 2025 ] 2>/dev/null; then g "system clock" "$(date '+%F %T')" + else w "system clock" "year=${yr:-?} looks wrong — TLS/Gemini may fail (no RTC; needs NTP)"; fi +fi + +# ── 2. Dashboard reachability ─────────────────────────────────────────────── +sec "Dashboard" +code="$(http "$BASE/")"; [ "$code" = 200 ] && g "dashboard /" "HTTP 200" || b "dashboard /" "HTTP ${code:-timeout}" +code="$(http "$BASE/api/system/info")" +if [ "$code" = 200 ]; then curl -s -m6 "$BASE/api/system/info" >"$INFO" 2>/dev/null; g "api/system/info" "HTTP 200" +else b "api/system/info" "HTTP ${code:-timeout}"; fi + +# ── 3. Robot DDS link + subsystems ────────────────────────────────────────── +sec "Robot link (DDS) & subsystems" +if [ -s "$INFO" ]; then + ddsif="$(jget "$INFO" 'd["dds"]["interface"]')" + [ -n "$ddsif" ] && g "DDS interface" "$ddsif" || b "DDS interface" "unset" + ifup="$(jget "$INFO" 'next((i["is_up"] and i["ip"].startswith("192.168.123.") for i in d["network"]["interfaces"] if i["name"]==d["dds"]["interface"]), False)')" + [ "$ifup" = "True" ] && g "robot NIC up" "$ddsif has 192.168.123.x" || b "robot NIC up" "$ddsif down or no 192.168.123.x IP (robot cable?)" + tot="$(jget "$INFO" 'd["subsystems"]["total"]')"; con="$(jget "$INFO" 'd["subsystems"]["connected"]')"; dis="$(jget "$INFO" 'd["subsystems"]["disconnected"]')" + # require the full registry (>=18) so an empty/short registry (total=0 after a core + # import failure) can NEVER read as green "0/0 connected". + if [ -n "$con" ] && [ "${tot:-0}" -ge 18 ] 2>/dev/null && [ "$con" = "$tot" ] && [ "${dis:-0}" = 0 ]; then g "subsystems" "$con/$tot connected" + else down="$(jget "$INFO" '",".join(s["name"] for s in d["subsystems"]["list"] if not s["connected"])')"; b "subsystems" "${con:-?}/${tot:-?} connected (expect >=18) — down: ${down:-registry empty}"; fi + # arm lowstate freshness = proof DDS is actually RECEIVING from the robot (subsystem + # "connected" only means the object was constructed, not that data is flowing) → FAIL if stale. + age="$(jget "$INFO" 'next((s["status"].get("state_age_sec") for s in d["subsystems"]["list"] if s["name"]=="arm"), None)')" + if [ -n "$age" ] && awk -v a="$age" 'BEGIN{exit !(a+0<5)}'; then g "lowstate fresh" "arm state_age=${age}s (robot state flowing)" + else b "lowstate fresh" "arm state_age=${age:-n/a}s — robot state NOT flowing (robot powered off / cable?)"; fi +else w "Robot link" "skipped (no system/info)"; fi + +# ── 4. Gemini voice ───────────────────────────────────────────────────────── +# The live session is lazy-started (connects on a wake-word / Live Voice toggle), +# so on an idle boot connected=False is NORMAL. We therefore verify readiness: +# connected -> PASS (a session is already up) +# reconnect_attempts>0 -> FAIL (actively trying + failing = bad key / network) +# else (lazy) -> PASS if the key is in the container AND the Gemini API +# is reachable; otherwise FAIL. +sec "Voice (Gemini)" +gc=""; ra="" +if [ "$(http "$BASE/api/voice/status")" = 200 ]; then + curl -s -m6 "$BASE/api/voice/status" >"$VOICE" 2>/dev/null + gc="$(jget "$VOICE" 'd["gemini"]["connected"]')" + ra="$(jget "$VOICE" 'd["gemini"].get("reconnect_attempts")')" +fi +if [ "$gc" = "True" ]; then + g "Gemini connected" "$(jget "$VOICE" 'd["gemini"]["model"]')" +elif [ "${ra:-0}" -gt 0 ] 2>/dev/null; then + b "Gemini connected" "failing after $ra reconnect attempts — bad key or network" +elif [ "$LOCAL" = 1 ]; then + docker exec sanadr1 sh -c '[ -n "$SANAD_GEMINI_API_KEY" ]' 2>/dev/null \ + && g "Gemini key" "present in container" || b "Gemini key" "SANAD_GEMINI_API_KEY not set in container" + gcode="$(docker exec sanadr1 sh -c 'curl -s -m6 -o /dev/null -w "%{http_code}" https://generativelanguage.googleapis.com/v1beta/models' 2>/dev/null)" + if [ -n "$gcode" ] && [ "$gcode" != 000 ]; then g "Gemini reachable" "API responded ($gcode) — lazy-connects on wake/live-voice" + else b "Gemini reachable" "no internet route to the Gemini API (http=${gcode:-timeout})"; fi +elif [ -s "$VOICE" ]; then + w "Gemini connected" "no live session yet (lazy-start — run locally to verify key+reachability)" +else + b "Gemini status" "api/voice/status unreachable" +fi + +# ── 5. Camera pipeline ────────────────────────────────────────────────────── +sec "Camera" +if [ "$(http "$BASE/api/recognition/state")" = 200 ]; then + curl -s -m6 "$BASE/api/recognition/state" >"$CAM" 2>/dev/null + src="$(jget "$CAM" 'd["camera"].get("source")')"; port="$(jget "$CAM" 'd["camera"].get("r1_port")')" + cerr="$(jget "$CAM" 'd["camera"].get("error")')"; dw="$(jget "$CAM" '"yes" if d["camera"].get("dewarp") else "no"')" + if [ -n "$src" ] && [ -n "$port" ]; then g "camera code" "source=$src port=$port dewarp=$dw (fixes present)" + elif [ "$cerr" = "camera subsystem unavailable" ]; then b "camera code" "camera subsystem unavailable (not loaded)" + else b "camera code" "r1_port/source missing — old image without the camera fixes"; fi + code="$(http "$BASE/api/recognition/stream.mjpg")"; [ "$code" = 200 ] && g "MJPEG endpoint" "HTTP 200" || b "MJPEG endpoint" "HTTP ${code:-timeout}" + run="$(jget "$CAM" '"yes" if d["camera"].get("running") else "no"')" + [ "$run" = yes ] && g "camera capturing" "backend=$(jget "$CAM" 'd["camera"].get("backend")')" \ + || w "camera capturing" "off — toggle Vision ON + start 'Stereo patch PC1' in the app" +else b "api/recognition/state" "unreachable"; fi + +# ── 6. Audio ──────────────────────────────────────────────────────────────── +sec "Audio" +if [ -s "$INFO" ]; then + pa="$(jget "$INFO" 'd["audio"]["pactl_available"]')"; [ "$pa" = "True" ] && g "PulseAudio" "available in container" || w "PulseAudio" "unavailable" + prof="$(jget "$INFO" '(d["audio"]["current"].get("profile") or {}).get("label")')" + [ -n "$prof" ] && g "audio profile" "$prof" || w "audio profile" "(none)" + sk="$(jget "$INFO" 'd["audio"]["current"].get("source_kind")')" + [ -n "$sk" ] && g "audio input" "source_kind=$sk" || w "audio input" "no active mic source — wake/ASR deaf" +else w "Audio" "skipped (no system/info)"; fi + +# ── 7. Container logs (local) ─────────────────────────────────────────────── +if [ "$LOCAL" = 1 ]; then + sec "Logs" + errs="$(docker logs --since 10m sanadr1 2>&1 | grep -iE 'traceback|critica|modulenotfound|importerror|address already in use' | grep -viE 'no frame|no backend|reconnect|stream off|no camera|RealSense unavailable|USB camera|no working|local_tts' | tail -3)" + [ -z "$errs" ] && g "no fatal errors" "clean startup logs" || w "log errors" "$(echo "$errs" | head -1)" +fi + +# ── summary ───────────────────────────────────────────────────────────────── +printf '\n\033[1m── Summary ──\033[0m \033[0;32m%d PASS\033[0m \033[0;33m%d WARN\033[0m \033[0;31m%d FAIL\033[0m\n' "$PASS" "$WARN" "$FAIL" +if [ "$FAIL" -gt 0 ]; then echo "RESULT: FAIL — see [FAIL] lines above."; exit 1; fi +[ "$WARN" -gt 0 ] && echo "RESULT: OK (with warnings — usually the camera stream / TTS model, both optional)." || echo "RESULT: ALL GREEN ✓" +exit 0 diff --git a/docker/install.sh b/docker/install.sh new file mode 100755 index 0000000..952f036 --- /dev/null +++ b/docker/install.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# SanadR1 turnkey installer — run this ON A FRESH R1 EDU BACKPACK. +# +# tar xzf sanadr1-bundle-*.tar.gz && cd sanadr1-bundle && ./install.sh +# +# Auto-detects the robot NIC, the current user/home, and USB audio — no values +# are hardcoded to any one robot. Idempotent: safe to re-run to update the image. +# +# The Gemini API key is taken from (first found): +# 1. $SANAD_GEMINI_API_KEY in the environment +# 2. an existing /.env +# 3. an interactive prompt +# ───────────────────────────────────────────────────────────────────────────── +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +IMG_TAR="$HERE/sanadr1-latest-arm64.tar.gz" +say() { printf '\033[1;36m>> %s\033[0m\n' "$*"; } +die() { printf '\033[1;31m!! %s\033[0m\n' "$*" >&2; exit 1; } + +# ── 1. preflight ──────────────────────────────────────────────────────────── +command -v docker >/dev/null 2>&1 || die "Docker is not installed. Install Docker Engine first, then re-run." +docker info >/dev/null 2>&1 || die "Docker daemon not reachable. Start it (sudo systemctl start docker) or add yourself to the 'docker' group." +[ -f "$IMG_TAR" ] || die "missing image tarball: $IMG_TAR (run this from the extracted bundle dir)." +arch="$(uname -m)" +[ "$arch" = "aarch64" ] || say "WARNING: arch=$arch — the image is arm64/aarch64; it will only run on an ARM64 backpack." + +# sudo helper (for the systemd unit); prompts once if needed. +SUDO=""; [ "$(id -u)" -eq 0 ] || SUDO="sudo" +$SUDO true 2>/dev/null || die "need sudo to install the systemd auto-start unit." + +# ── 2. auto-detect the robot facts ────────────────────────────────────────── +IFACE="$(ip -o -4 addr show 2>/dev/null | awk '$4 ~ /^192\.168\.123\./{print $2; exit}')" +[ -n "$IFACE" ] || { IFACE=eth10; say "no NIC on 192.168.123.x yet (robot link down?) — defaulting to eth10"; } +RUN_USER="$(id -un)"; RUN_HOME="$HOME" +DEST="$RUN_HOME/SanadR1/docker" +say "robot NIC: $IFACE" +say "install as: user=$RUN_USER home=$RUN_HOME" +say "install to: $DEST" + +# ── 3. load the image ─────────────────────────────────────────────────────── +say "loading sanadr1:latest (this can take ~30-60 s)…" +gunzip -c "$IMG_TAR" | docker load +docker image inspect sanadr1:latest >/dev/null 2>&1 || die "image did not load." + +# ── 4. drop the run recipe ────────────────────────────────────────────────── +mkdir -p "$DEST" +cp "$HERE/run.sh" "$DEST/run.sh"; chmod +x "$DEST/run.sh" +cp "$HERE/healthcheck.sh" "$DEST/healthcheck.sh"; chmod +x "$DEST/healthcheck.sh" + +# ── 5. .env (key + detected interface) ────────────────────────────────────── +ENV_FILE="$DEST/.env" +if [ -f "$ENV_FILE" ] && grep -q '^SANAD_GEMINI_API_KEY=.\+' "$ENV_FILE"; then + say "keeping existing $ENV_FILE (already has a key)" +else + KEY="${SANAD_GEMINI_API_KEY:-}" + if [ -z "$KEY" ]; then + printf 'Enter SANAD_GEMINI_API_KEY (paste, then Enter): ' + read -r KEY + fi + [ -n "$KEY" ] || die "no Gemini API key given — set SANAD_GEMINI_API_KEY or paste it when prompted." + umask 077 + printf 'SANAD_GEMINI_API_KEY=%s\nSANAD_DDS_INTERFACE=%s\n' "$KEY" "$IFACE" > "$ENV_FILE" + say "wrote $ENV_FILE (mode 600)" +fi +# ensure the interface line is present even if .env was pre-supplied +grep -q '^SANAD_DDS_INTERFACE=' "$ENV_FILE" || printf 'SANAD_DDS_INTERFACE=%s\n' "$IFACE" >> "$ENV_FILE" + +# ── 6. systemd auto-start unit (generated with the detected user/path) ────── +say "installing systemd unit → /etc/systemd/system/sanadr1.service" +$SUDO tee /etc/systemd/system/sanadr1.service >/dev/null </dev/null 2>&1 || $DEST/run.sh' +ExecStart=/usr/bin/docker start sanadr1 +ExecStop=/usr/bin/docker stop sanadr1 + +[Install] +WantedBy=multi-user.target +EOF +$SUDO systemctl daemon-reload + +# ── 7. first run + enable on boot ─────────────────────────────────────────── +say "starting the container…" +"$DEST/run.sh" +$SUDO systemctl enable sanadr1 >/dev/null 2>&1 || true +# Activate the oneshot too (container is already up; ExecStartPre sees it and +# skips run.sh, ExecStart's `docker start` is a no-op) so `systemctl is-active` +# reads 'active' now, not just after the next reboot. +$SUDO systemctl start sanadr1 >/dev/null 2>&1 || true + +# ── 8. verify (full health check) ─────────────────────────────────────────── +say "waiting for the dashboard…" +up=0 +for i in $(seq 1 25); do curl -fsS -m3 http://127.0.0.1:8001/ >/dev/null 2>&1 && { up=1; break; }; sleep 2; done +[ "$up" = 1 ] || die "dashboard did not answer in ~50 s. Check: docker logs -f sanadr1" + +WIFI="$(ip -o -4 addr show 2>/dev/null | awk '$4 ~ /^10\.|^192\.168\.[0-9]+\./ && $2!="lo"{print $4}' | cut -d/ -f1 | head -1)" +say "dashboard up → http://${WIFI:-}:8001 — running full health check…" +echo +if "$DEST/healthcheck.sh"; then + say "auto-start enabled. Manage with: sudo systemctl {status,restart,stop} sanadr1" + exit 0 +else + say "install finished but the health check reported failures (see above). Re-run: $DEST/healthcheck.sh" + exit 1 +fi diff --git a/docker/make_bundle.sh b/docker/make_bundle.sh new file mode 100755 index 0000000..b8e1bca --- /dev/null +++ b/docker/make_bundle.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# Build a self-contained SanadR1 install bundle for ANY R1 EDU backpack. +# Run this ON THE WORKSTATION (x86). Produces: +# /tmp/sanadr1-bundle-.tar.gz containing: +# sanadr1-latest-arm64.tar.gz (the image) install.sh run.sh +# .env.example README.md VERSION +# +# Usage: +# ./make_bundle.sh # reuse the current sanadr1:latest image (fast) +# ./make_bundle.sh --build # cross-build the arm64 image first (slow) +# +# Copy the resulting bundle to a robot and run install.sh there. No registry, +# no internet, no source needed on the robot. +# ───────────────────────────────────────────────────────────────────────────── +set -euo pipefail +R="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # the SanadR1 folder +DATE="$(date +%Y%m%d)" +STAGE="/tmp/sanadr1-bundle" +OUT="/tmp/sanadr1-bundle-${DATE}.tar.gz" +say() { printf '\033[1;36m>> %s\033[0m\n' "$*"; } + +if [ "${1:-}" = "--build" ]; then + say "cross-building arm64 image (cached layers reused)…" + docker buildx build --builder armbuilder --platform linux/arm64 \ + -f "$R/docker/Dockerfile" -t sanadr1:latest --load "$R" +else + docker image inspect sanadr1:latest >/dev/null 2>&1 \ + || { echo "!! sanadr1:latest not present — run with --build first"; exit 1; } + say "reusing existing sanadr1:latest (pass --build to rebuild)" +fi + +rm -rf "$STAGE"; mkdir -p "$STAGE" +say "saving image → tarball…" +docker save sanadr1:latest | gzip > "$STAGE/sanadr1-latest-arm64.tar.gz" + +cp "$R/docker/install.sh" "$R/docker/run.sh" "$R/docker/healthcheck.sh" "$R/docker/.env.example" "$STAGE/" +cp "$R/docker/README.md" "$STAGE/README.md" +chmod +x "$STAGE/install.sh" "$STAGE/run.sh" "$STAGE/healthcheck.sh" +{ echo "sanadr1 bundle $DATE"; docker image inspect sanadr1:latest --format 'image_id={{.Id}} arch={{.Architecture}}'; } > "$STAGE/VERSION" + +say "packing bundle…" +tar czf "$OUT" -C "$(dirname "$STAGE")" "$(basename "$STAGE")" +ls -lh "$OUT" +say "DONE → $OUT" +say "Deploy on any R1: scp $OUT r1:~/ ; ssh r1 'tar xzf $(basename "$OUT") && cd sanadr1-bundle && ./install.sh'" diff --git a/docker/requirements.docker.txt b/docker/requirements.docker.txt new file mode 100644 index 0000000..e063ef7 --- /dev/null +++ b/docker/requirements.docker.txt @@ -0,0 +1,14 @@ +# SanadR1 container Python deps — pinned to the known-good GeminiR1 conda env. +# The BLE LED-mask was removed, so `bleak` is intentionally gone. +# cyclonedds==0.10.2 and unitree_sdk2py are installed separately in the Dockerfile +# (built-from-source C lib / vendored wheel — neither has an aarch64 PyPI wheel). +fastapi==0.139.0 +uvicorn[standard]==0.49.0 +python-multipart==0.0.32 +google-genai==2.10.0 +websockets==16.0 +PyAudio==0.2.14 +opencv-python-headless==5.0.0.93 +Pillow==12.3.0 +numpy==2.2.6 +requests==2.34.2 diff --git a/docker/run.sh b/docker/run.sh new file mode 100755 index 0000000..9fa2d84 --- /dev/null +++ b/docker/run.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Run SanadR1 as a container on the backpack WITHOUT docker-compose (the backpack +# ships no compose plugin). This mirrors docker-compose.yml exactly. Idempotent: +# recreates the container from the already-loaded sanadr1:latest image. +# +# cd ~/SanadR1/docker && ./run.sh +# +# Auto-start on boot is handled by --restart unless-stopped + the docker service. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +[ -f "$HERE/.env" ] || { echo "!! $HERE/.env missing (set SANAD_GEMINI_API_KEY)"; exit 1; } +docker image inspect sanadr1:latest >/dev/null 2>&1 || { + echo "!! image sanadr1:latest not loaded — docker load < sanadr1-latest-arm64.tar.gz"; exit 1; } + +# Pass the host's USB sound cards in when present (Anker/JBL-style external +# audio). gid 29 = the host 'audio' group that owns /dev/snd, so the in-container +# app user can reach it. Absent → the container runs DDS-audio-only as before. +SND_ARGS=() +if [ -e /dev/snd ]; then + SND_ARGS=(--device /dev/snd --group-add 29) + echo ">> /dev/snd found — enabling USB audio passthrough" +fi + +# Robot DDS interface — portable across R1 backpacks. If .env pins one +# (install.sh writes the detected value there) we defer to it; otherwise +# auto-detect the NIC holding a 192.168.123.x address (the R1 internal net), +# falling back to eth10 (the standard EDU-backpack name). The entrypoint also +# defaults to eth10, so this is belt-and-suspenders. +IFACE_ARGS=() +if ! grep -q '^SANAD_DDS_INTERFACE=' "$HERE/.env" 2>/dev/null; then + IFACE="$(ip -o -4 addr show 2>/dev/null | awk '$4 ~ /^192\.168\.123\./{print $2; exit}')" + [ -n "$IFACE" ] || IFACE=eth10 + IFACE_ARGS=(-e "SANAD_DDS_INTERFACE=$IFACE") + echo ">> DDS interface auto-detected: $IFACE" +fi + +docker rm -f sanadr1 2>/dev/null || true +docker run -d --name sanadr1 \ + --network host \ + --restart unless-stopped \ + --env-file "$HERE/.env" \ + "${IFACE_ARGS[@]}" \ + -e SANAD_DASHBOARD_HOST=0.0.0.0 \ + -e SANAD_NAV_TOOLS=0 \ + -e SANAD_AUDIO_PROFILE=builtin \ + -e SANAD_VOICE_BRAIN=gemini \ + "${SND_ARGS[@]}" \ + -v sanadr1_data:/opt/SanadR1/data \ + -v sanadr1_logs:/opt/SanadR1/logs \ + sanadr1:latest + +echo ">> started. logs: docker logs -f sanadr1 dashboard: http://:8001" diff --git a/docker/sanadr1.service b/docker/sanadr1.service new file mode 100644 index 0000000..3541112 --- /dev/null +++ b/docker/sanadr1.service @@ -0,0 +1,22 @@ +# systemd unit that manages the SanadR1 Docker container on the R1 backpack. +# Install: sudo cp sanadr1.service /etc/systemd/system/ && sudo systemctl daemon-reload +# sudo systemctl enable --now sanadr1 +# Coexists with the container's own --restart unless-stopped (belt-and-suspenders): +# systemd guarantees create+start on boot and gives systemctl start/stop/status. +[Unit] +Description=SanadR1 (Docker) - Gemini voice + dashboard on the R1 EDU backpack +Requires=docker.service +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +# Self-healing: if the container was removed (e.g. after an image reload), recreate +# it from run.sh; otherwise just (re)start the existing one. +ExecStartPre=-/bin/sh -c 'docker inspect sanadr1 >/dev/null 2>&1 || /home/unitree/SanadR1/docker/run.sh' +ExecStart=/usr/bin/docker start sanadr1 +ExecStop=/usr/bin/docker stop sanadr1 + +[Install] +WantedBy=multi-user.target diff --git a/docker/vendor/crc_aarch64.so b/docker/vendor/crc_aarch64.so new file mode 100644 index 0000000..878df74 Binary files /dev/null and b/docker/vendor/crc_aarch64.so differ diff --git a/docker/vendor/crc_amd64.so b/docker/vendor/crc_amd64.so new file mode 100644 index 0000000..258055d Binary files /dev/null and b/docker/vendor/crc_amd64.so differ diff --git a/docker/vendor/unitree_sdk2py-1.0.1-py3-none-any.whl b/docker/vendor/unitree_sdk2py-1.0.1-py3-none-any.whl new file mode 100644 index 0000000..3231178 Binary files /dev/null and b/docker/vendor/unitree_sdk2py-1.0.1-py3-none-any.whl differ diff --git a/data/recordings/20260514_175244_robot.wav b/face/__init__.py similarity index 100% rename from data/recordings/20260514_175244_robot.wav rename to face/__init__.py diff --git a/gemini/client.py b/gemini/client.py index b300da0..befe369 100644 --- a/gemini/client.py +++ b/gemini/client.py @@ -37,6 +37,22 @@ _DEFAULT_SYSTEM_PROMPT = _cfg_section("core", "gemini_defaults").get( "You are Sanad (Bousandah), a wise and friendly Emirati assistant. " "Speak in UAE dialect (Khaleeji). Be helpful and concise." ) + +# TTS / typed-replay system prompt. The voice_client speaks TYPED text (typed +# replay + /api/voice/generate), so it must read the text VERBATIM in its OWN +# language — NOT answer it and NOT force Khaleeji (the default persona does the +# latter, which made English/Urdu/Indonesian lines come out in Arabic). +TTS_SYSTEM_PROMPT = _cfg_section("core", "gemini_defaults").get( + "tts_system_prompt", + "You are a pure multilingual text-to-speech voice. The instant the user " + "sends text, speak it aloud word for word in the SAME language it is " + "written in, then stop. Output ONLY that spoken audio — no thinking, no " + "commentary, no acknowledgements, no headers, no explanations, no " + "greetings, no extra words. Never translate and never change the language: " + "English stays English, Arabic stays Arabic, Urdu stays Urdu, Indonesian " + "stays Indonesian. Your speech must be identical to the user's text, " + "nothing more and nothing less." +) _RECV_TIMEOUT_SEC = _GC.get("recv_timeout_sec", 30) _RECONNECT_MAX_ATTEMPTS = _GC.get("reconnect_max_attempts", 3) _RECONNECT_INITIAL_DELAY_SEC = _GC.get("reconnect_initial_delay_sec", 1.0) diff --git a/gemini/script.py b/gemini/script.py index 569e486..83ebeb2 100644 --- a/gemini/script.py +++ b/gemini/script.py @@ -60,10 +60,34 @@ _SESSION_TIMEOUT = _SV.get("session_timeout_sec", 660) _MAX_RECONNECT_DELAY = _SV.get("max_reconnect_delay_sec", 30) _MAX_CONSECUTIVE_ERRORS = _SV.get("max_consecutive_errors", 10) _NO_MESSAGES_TIMEOUT = _SV.get("no_messages_timeout_sec", 30) +# Extra mic-gate time after the AI stops, on loud external-speaker profiles +# (JBL) — covers the speaker buffer + room reverb so it doesn't hear its tail. +_ECHO_TAIL_SEC = _SV.get("echo_tail_sec", 0.6) +# On a loud external speaker (JBL) barge-in must clear the measured speaker +# BLEED by this factor — so the user's own voice cuts the AI but the speaker's +# echo into the mic does not. Lower = easier to interrupt (more false cuts). +_JBL_BLEED_MARGIN = _SV.get("jbl_bleed_margin", 3.0) +# Don't allow barge-in for this long after the AI starts on the JBL — gives the +# bleed estimate time to ramp so an early loud bleed frame can't false-trigger. +_JBL_BARGE_GRACE = _SV.get("jbl_barge_grace_sec", 1.0) +# Sustained loud-frame count required to barge-in on the JBL (vs the shorter +# default). Brief speaker-echo peaks won't reach it; continuous speech will. +_JBL_BARGE_CHUNKS = _SV.get("jbl_barge_chunks", 9) +# Time since the AI last pushed audio after which the speaker bleed is assumed +# FADED (a gap between words/numbers). In that window barge-in drops to a low, +# sensitive bar so the user can interrupt; while audio is flowing it stays high. +_JBL_BLEED_FADE_SEC = _SV.get("jbl_bleed_fade_sec", 0.5) _CHUNK_BYTES = CHUNK_SIZE * 2 _SILENCE_PCM = b"\x00" * _CHUNK_BYTES +# Set by a "pause:1" stdin command (a record is playing on the dashboard). +# While set, the brain feeds Gemini silence (so it neither hears the playback +# nor keeps replying) and drops its own audio output (so the record owns the +# chest speaker). Cleared by "pause:0" when playback ends. threading.Event is +# safe to read from the asyncio loops. +_INPUT_PAUSED = threading.Event() + # ── Recognition (camera + face gallery) tunables ── _RECOG_STATE_PATH = Path(os.environ.get( "SANAD_RECOGNITION_STATE_PATH", @@ -87,6 +111,90 @@ _ZONES_DIR = Path(os.environ.get( )) +# ── navigation tools (Gemini Live function-calling → Nav2) ──── +# Gemini can DRIVE the robot to saved places via native function-calling. +# The handlers call the DASHBOARD HTTP API (not web_nav3 directly) so the +# in-process _arbiter (nav↔loco mutex) + single nav client stay authoritative +# — the Gemini brain runs as a SEPARATE subprocess and cannot touch the +# dashboard's in-memory arbiter, so it must go through HTTP. +try: + import requests as _requests +except Exception: # pragma: no cover - requests is in the gemini_sdk env + _requests = None + +_NAV_TOOLS_ENABLED = os.environ.get("SANAD_NAV_TOOLS", "1") != "0" +_DASHBOARD_URL = os.environ.get( + "SANAD_DASHBOARD_URL", "http://127.0.0.1:8001" +).rstrip("/") + + +def _nav_api(method: str, path: str, body: Optional[dict] = None, + timeout: float = 12.0) -> dict: + """Blocking call to the dashboard nav API. Always returns a dict; never + raises (run it via asyncio.to_thread so the receive loop stays responsive).""" + if _requests is None: + return {"ok": False, "reason": "no_http_client"} + url = _DASHBOARD_URL + path + try: + if method == "GET": + resp = _requests.get(url, timeout=timeout) + else: + resp = _requests.post(url, json=(body or {}), timeout=timeout) + except Exception as exc: + return {"ok": False, "reason": "unreachable", "detail": str(exc)[:200]} + try: + data = resp.json() + except Exception: + data = {"raw": (resp.text or "")[:200]} + if not resp.ok: + detail = data.get("detail") if isinstance(data, dict) else data + return {"ok": False, "reason": f"http_{resp.status_code}", "detail": detail} + return data if isinstance(data, dict) else {"ok": True, "data": data} + + +def _nav_function_declarations() -> list: + """Gemini Live tool declarations for navigation (built lazily so the + google.genai types are resolved at call time).""" + S, T = types.Schema, types.Type + return [ + types.FunctionDeclaration( + name="navigate_to_place", + description=( + "Drive the robot to a named saved place in the currently loaded " + "map (for example 'kitchen', 'reception', 'office'). Call this " + "ONLY when the user asks to go, move, walk, or be taken " + "somewhere. The place must exist in the active map — if you are " + "unsure of the name, call list_places first." + ), + parameters=S(type=T.OBJECT, properties={ + "place": S(type=T.STRING, + description="Destination place name, as the user said it."), + }, required=["place"]), + ), + types.FunctionDeclaration( + name="list_places", + description=( + "List the saved places you can drive to in the currently loaded " + "map. Use it to tell the user where you can take them." + ), + parameters=S(type=T.OBJECT, properties={}), + ), + types.FunctionDeclaration( + name="where_am_i", + description=( + "Report navigation status: which map is loaded and whether the " + "robot is localized and ready to drive." + ), + parameters=S(type=T.OBJECT, properties={}), + ), + types.FunctionDeclaration( + name="stop_navigation", + description="Cancel the current navigation goal and stop the robot from driving.", + parameters=S(type=T.OBJECT, properties={}), + ), + ] + + # ── stdin push channel (Marcus pattern) ────────────────────── # The GeminiSubprocess supervisor writes two line types to this process's # stdin: @@ -108,6 +216,15 @@ _STATE_TAGS = { "error": "[STATE-ERROR]", "paused": "[STATE-PAUSED]", "resumed": "[STATE-RESUMED]", + # Navigation arrival/failure — pushed by the dashboard goal monitor so + # Gemini can truthfully tell the user it arrived (or couldn't get there) + # instead of guessing from the fire-and-forget goto. + "nav_arrived": "[NAV ARRIVED]", + "nav_failed": "[NAV FAILED]", + "nav_canceled": "[NAV CANCELED]", + # "Gemini Nav" session greeting — the operator entered a zone bound to a + # map; tell Gemini the zone + drivable places and to greet the user. + "nav_zone": "[GEMINI NAV]", } # Pending audio-profile swap signalled by the parent over "profile:" stdin @@ -115,7 +232,8 @@ _STATE_TAGS = { _PROFILE_LOCK = threading.Lock() _PROFILE_PENDING: dict = {"id": None, "reason": ""} -_VALID_PROFILES = {"builtin", "anker", "hollyland_builtin"} +_VALID_PROFILES = {"builtin", "anker", "anker_powerconf", + "hollyland_builtin", "jbl_builtin_mic"} def _stdin_watcher() -> None: @@ -124,7 +242,17 @@ def _stdin_watcher() -> None: Best-effort: any malformed line is skipped. Exits when the parent closes our stdin (subprocess teardown).""" try: - for line in sys.stdin: + # IMPORTANT: read with readline(), NOT `for line in sys.stdin`. The file + # iterator does aggressive read-ahead buffering, so on an idle pipe a + # small command like "pause:1\n" can sit unread for SECONDS (until more + # stdin data arrives to flush the read-ahead). That delayed the record- + # playback pause by ~2s — Gemini kept stomping the chest speaker so the + # clip was silent / late. readline() returns each line as soon as its + # newline arrives, so commands are delivered promptly. + while True: + line = sys.stdin.readline() + if line == "": + break # EOF — parent closed our stdin (subprocess teardown) line = line.rstrip("\n") if not line: continue @@ -172,6 +300,16 @@ def _stdin_watcher() -> None: _PROFILE_PENDING["id"] = pid _PROFILE_PENDING["reason"] = ( payload.get("reason") or "").strip() + elif line.startswith("pause:"): + # Dashboard record playback — pause/resume the live interaction. + if line[len("pause:"):].strip() in ("1", "true", "True", "on"): + if not _INPUT_PAUSED.is_set(): + _INPUT_PAUSED.set() + log.info("input PAUSED — record playback") + else: + if _INPUT_PAUSED.is_set(): + _INPUT_PAUSED.clear() + log.info("input RESUMED — record playback ended") except Exception: return @@ -211,7 +349,7 @@ class GeminiBrain: self._swap_lock: Optional[asyncio.Lock] = None # built in run() self._recorder = recorder self._voice = voice_name or GEMINI_VOICE - self._system_prompt = system_prompt + self._system_prompt = system_prompt or "" self._api_key = GEMINI_API_KEY self._stop_flag = asyncio.Event() # per-session state (reset in the outer reconnect loop) @@ -220,6 +358,9 @@ class GeminiBrain: self._barge_block_until = 0.0 self._ai_speak_start = 0.0 self._last_ai_audio = 0.0 + # Rolling estimate of the speaker bleed picked up by the mic while the AI + # talks (JBL profile) — the barge-in threshold floats above this. + self._bleed_ewma = 0.0 self._done: Optional[asyncio.Event] = None # ── Recognition flags — kept in sync with the state file by # _recognition_state_watcher. Boot defaults come from the file (or @@ -250,6 +391,13 @@ class GeminiBrain: _initial.movement_enabled or os.environ.get("SANAD_MOVEMENT_ENABLE", "0") == "1" ) + # Auto-record toggle — recognition_state is the live source of truth. + # Sync the recorder to it now; the watcher keeps it in sync at runtime. + self._record_enabled = bool(_initial.record_enabled) + try: + self._recorder.enabled = self._record_enabled + except Exception: + pass def stop(self) -> None: """Signal the run loop to exit at the next opportunity.""" @@ -270,6 +418,10 @@ class GeminiBrain: while not self._stop_flag.is_set(): session_num += 1 self._reset_turn_state() + # On a reconnect (not the first session), suppress the unprompted + # re-greeting until the user speaks — keeps the chest speaker free + # for record playback and stops the "robot greets every 30s" loop. + self._suppress_greeting = session_num > 1 uptime_min = (time.time() - start_time) / 60 try: @@ -366,6 +518,11 @@ class GeminiBrain: system_instruction=types.Content( parts=[types.Part(text=self._system_prompt)], ), + # Native function-calling: Gemini can drive the robot to saved + # places (navigate_to_place / list_places / where_am_i / + # stop_navigation). Disable with SANAD_NAV_TOOLS=0. + tools=([types.Tool(function_declarations=_nav_function_declarations())] + if _NAV_TOOLS_ENABLED else []), ) # ─── state helpers ──────────────────────────────────── @@ -376,6 +533,16 @@ class GeminiBrain: self._barge_block_until = 0.0 self._ai_speak_start = 0.0 self._last_ai_audio = 0.0 + # Rolling estimate of the speaker bleed picked up by the mic while the AI + # talks (JBL profile) — the barge-in threshold floats above this. + self._bleed_ewma = 0.0 + # Suppress the unprompted greeting on a RECONNECT (set per-session in + # run() for session_num>1). The idle watchdog reconnects every ~30s when + # no one talks, and a fresh session greets each time ("مرحبابك…") which + # floods the shared chest speaker and stomps record playback. We drop + # that greeting's audio until the user actually speaks. Default False so + # the FIRST session (startup) greets normally. + self._suppress_greeting = False def _interrupt(self, source: str = "local") -> None: self._speaking = False @@ -411,9 +578,18 @@ class GeminiBrain: data = samples.tobytes() energy = _audio_energy(data) now = time.time() + # On the JBL (loud external speaker) the head mic hears the robot's + # OWN voice as loud as the user. We FULLY gate the mic to Gemini while + # it speaks (+ a short echo tail) so it NEVER hears itself, and we + # DISABLE voice barge-in there — the bleed is as loud as your voice, so + # energy can't separate them and any attempt leaks the echo back to the + # model. (Reliable JBL interrupt needs AEC; the only PulseAudio mic is + # dead, so that's separate work.) The chest speaker (builtin) keeps + # light quiet-frame suppression + working barge-in (firmware AEC). + full_gate = "jbl" in (self._current_profile_id or "") - # Barge-in: after AI starts speaking, sustained user energy cuts it. - if self._speaking and now >= self._barge_block_until: + # Barge-in: sustained user energy cuts the AI — chest profile only. + if self._speaking and not full_gate and now >= self._barge_block_until: if (now - self._ai_speak_start) >= grace: if energy > threshold: loud_count += 1 @@ -425,10 +601,18 @@ class GeminiBrain: loud_count = 0 self._barge_block_until = now + cooldown - # Echo suppression: while AI is speaking, mask quiet frames so the - # mic doesn't feed the model its own voice bleed. + # Echo suppression: mask the mic so the model doesn't hear its own bleed. send_data = data - if self._speaking and energy < echo_suppress_below: + if _INPUT_PAUSED.is_set(): + # Paused for a record playback — feed silence so Gemini neither + # hears the record nor keeps talking over it. + send_data = _SILENCE_PCM + elif full_gate and (self._speaking + or (now - self._last_ai_audio) < _ECHO_TAIL_SEC): + # Loud external speaker: gate ALL frames while speaking + tail — + # this is what guarantees it never hears itself. + send_data = _SILENCE_PCM + elif self._speaking and energy < echo_suppress_below: send_data = _SILENCE_PCM # Record user audio when clearly speaking and AI isn't. @@ -466,83 +650,159 @@ class GeminiBrain: async def _receive_loop(self, session: Any) -> None: loop = asyncio.get_event_loop() try: - last_recv = time.time() while not self._done.is_set() and not self._stop_flag.is_set(): - async for response in session.receive(): - last_recv = time.time() - if self._done.is_set(): - break - - if hasattr(response, "go_away") and response.go_away is not None: - log.info("server going away — will reconnect") - self._done.set() - return - - sc = response.server_content - if sc is None: - continue - - if sc.interrupted is True: - if self._speaking: - log.info("Gemini interrupted") - self._interrupt("gemini") - continue - - if sc.input_transcription: - text = (sc.input_transcription.text or "").strip() - if text and not self._speaking: - log.info("USER: %s", text) - self._recorder.add_user_text(text) - - if sc.output_transcription: - text = (sc.output_transcription.text or "").strip() - if text: - # Emit as "BOT:" (no space before colon) so the - # supervisor's _track_line can parse it the same - # way it parses "USER:" — this is the channel the - # movement dispatcher (N2) reads Gemini's own - # spoken phrases from. Keep in lock-step with - # GeminiSubprocess._track_line. - log.info("BOT: %s", text) - self._recorder.add_robot_text(text) - - if sc.model_turn: - for part in sc.model_turn.parts: - if part.inline_data and part.inline_data.data: - now = time.time() - if not self._speaking: - self._ai_speak_start = now - self._speaking = True - self._last_ai_audio = now - raw_audio = part.inline_data.data - self._recorder.capture_robot(raw_audio) - audio = np.frombuffer(raw_audio, dtype=np.int16) - if not self._stream_started: - await loop.run_in_executor( - None, self._speaker.begin_stream, - ) - self._stream_started = True - await loop.run_in_executor( - None, self._speaker.send_chunk, - audio, RECEIVE_SAMPLE_RATE, - ) - - if sc.turn_complete: - if (self._speaking and self._stream_started - and not self._speaker.interrupted): - log.info("speaker %.1fs", self._speaker.total_sent_sec) - await loop.run_in_executor( - None, self._speaker.wait_finish, + # Iterate session.receive() with a PER-MESSAGE timeout. A plain + # `async for` parks inside the generator on a silent/half-open + # stall (server stops sending but never closes the socket), so + # the no-message watchdog below — which only ran after the + # async-for ended a cycle — could not fire, and recovery waited + # out the 660s outer session cap. Driving __anext__ under + # wait_for(_NO_MESSAGES_TIMEOUT) detects a stall in ~Ns. + _recv_agen = session.receive() + _recv_it = _recv_agen.__aiter__() + _stalled = False + try: + while True: + try: + response = await asyncio.wait_for( + _recv_it.__anext__(), + timeout=_NO_MESSAGES_TIMEOUT, ) - elif self._speaking and self._speaker.interrupted: - log.info("speaker interrupted") - self._speaking = False - self._stream_started = False - self._mic.flush() - self._recorder.finish_turn() - log.info("listening") + except StopAsyncIteration: + break # generator exhausted — same as async-for end + except asyncio.TimeoutError: + _stalled = True + break + if self._done.is_set(): + break - if time.time() - last_recv > _NO_MESSAGES_TIMEOUT: + if hasattr(response, "go_away") and response.go_away is not None: + log.info("server going away — will reconnect") + self._done.set() + return + + # Native function-calling: Gemini asks us to run a tool + # (navigation). Handle it + reply, then continue — a + # tool_call message carries no server_content to process. + tc = getattr(response, "tool_call", None) + if tc is not None and getattr(tc, "function_calls", None): + await self._handle_tool_calls(session, tc.function_calls) + continue + + sc = response.server_content + if sc is None: + continue + + if sc.interrupted is True: + if self._speaking: + log.info("Gemini interrupted") + self._interrupt("gemini") + continue + + if sc.input_transcription: + text = (sc.input_transcription.text or "").strip() + if text and not self._speaking: + log.info("USER: %s", text) + self._recorder.add_user_text(text) + # The user actually said something (real + # transcription, not mic noise) → stop suppressing + # the reconnect greeting so Gemini's reply is heard. + if self._suppress_greeting: + self._suppress_greeting = False + log.info("reconnect greeting suppression " + "lifted — user spoke") + + if sc.output_transcription: + text = (sc.output_transcription.text or "").strip() + if text: + # Emit as "BOT:" (no space before colon) so the + # supervisor's _track_line can parse it the same + # way it parses "USER:" — this is the channel the + # movement dispatcher (N2) reads Gemini's own + # spoken phrases from. Keep in lock-step with + # GeminiSubprocess._track_line. + log.info("BOT: %s", text) + self._recorder.add_robot_text(text) + + if sc.model_turn: + for part in sc.model_turn.parts: + if part.inline_data and part.inline_data.data: + if _INPUT_PAUSED.is_set() or self._suppress_greeting: + # Drop Gemini's audio AND halt any in-flight + # stream at the source. Two cases: (1) a record + # is playing (_INPUT_PAUSED) — Gemini's per-chunk + # PlayStream("sanad") must not stomp the record on + # the shared chest speaker; (2) this is a reconnect + # and the user hasn't spoken — drop the unprompted + # re-greeting. Gated on _stream_started so STOP + # fires once (not per chunk); the next turn's + # begin_stream() clears the stop-flag and resumes. + if self._stream_started: + await loop.run_in_executor( + None, self._speaker.stop) + self._stream_started = False + self._speaking = False + continue + now = time.time() + if not self._speaking: + self._ai_speak_start = now + self._speaking = True + self._last_ai_audio = now + raw_audio = part.inline_data.data + self._recorder.capture_robot(raw_audio) + audio = np.frombuffer(raw_audio, dtype=np.int16) + if not self._stream_started: + await loop.run_in_executor( + None, self._speaker.begin_stream, + ) + self._stream_started = True + await loop.run_in_executor( + None, self._speaker.send_chunk, + audio, RECEIVE_SAMPLE_RATE, + ) + # Lip-sync marker for the LED face mask: emit the + # mouth-open level (0..3) from this chunk's RMS, + # throttled. Parsed by GeminiSubprocess._reader_loop. + _mnow = time.time() + if _mnow - getattr(self, "_mouth_t", 0.0) >= 0.08: + _rms = (float(np.sqrt(np.mean( + audio.astype(np.float32) ** 2))) if audio.size else 0.0) + # Lower thresholds bias the mouth more open + # so lip-sync reads strongly (vs. barely moving). + _lvl = (0 if _rms < 140 else 1 if _rms < 650 + else 2 if _rms < 1700 else 3) + if (_lvl != getattr(self, "_mouth_lvl", -1) + or _mnow - getattr(self, "_mouth_t", 0.0) >= 0.2): + self._mouth_t = _mnow + self._mouth_lvl = _lvl + log.info("[[MOUTH:%d]]", _lvl) + + if sc.turn_complete: + if (self._speaking and self._stream_started + and not self._speaker.interrupted): + log.info("speaker %.1fs", self._speaker.total_sent_sec) + await loop.run_in_executor( + None, self._speaker.wait_finish, + ) + elif self._speaking and self._speaker.interrupted: + log.info("speaker interrupted") + self._speaking = False + self._stream_started = False + if getattr(self, "_mouth_lvl", 0) != 0: + self._mouth_lvl = 0 + log.info("[[MOUTH:0]]") # close the LED-mask mouth + self._mic.flush() + self._recorder.finish_turn() + log.info("listening") + finally: + # Close the per-cycle receive generator so a stall/break + # doesn't leak it (the old `async for` closed it for us). + try: + await _recv_agen.aclose() + except Exception: + pass + + if _stalled: log.warning("no messages from Gemini for %ds — session dead", _NO_MESSAGES_TIMEOUT) break @@ -748,6 +1008,72 @@ class GeminiBrain: except Exception as exc: log.warning("nav-target inject failed: %s", exc) + # ─── navigation tool-call handler (Gemini function-calling) ─── + # Gemini issues tool_calls (navigate_to_place / list_places / where_am_i / + # stop_navigation); we execute them against the dashboard nav API and reply + # with a FunctionResponse so the model can speak from the real result. + + async def _handle_tool_calls(self, session: Any, function_calls: Any) -> None: + responses = [] + for fc in function_calls: + name = getattr(fc, "name", "") or "" + try: + args = dict(getattr(fc, "args", None) or {}) + except Exception: + args = {} + log.info("TOOL CALL: %s(%s)", name, args) + result = await self._dispatch_tool(name, args) + log.info("TOOL RESULT: %s → %s", name, result) + responses.append(types.FunctionResponse( + id=getattr(fc, "id", None), name=name, response=result, + )) + if not responses: + return + try: + await session.send_tool_response(function_responses=responses) + except asyncio.CancelledError: + raise + except Exception as exc: + log.warning("send_tool_response failed: %s", exc) + + async def _dispatch_tool(self, name: str, args: dict) -> dict: + try: + if name == "navigate_to_place": + # Respect the movement gate (kept fresh by the state watcher) so + # a nav call can't drive while walking is disabled. + if not self._movement_enabled: + return {"ok": False, "reason": "movement_off", + "say": "Movement is off — ask the user to enable it from the dashboard."} + place = str(args.get("place") or "").strip() + if not place: + return {"ok": False, "reason": "no_place"} + return await asyncio.to_thread( + _nav_api, "POST", "/api/nav/voice_goto", {"place": place}) + if name == "list_places": + r = await asyncio.to_thread(_nav_api, "GET", "/api/nav/active", None) + if not isinstance(r, dict): + return {"ok": False, "reason": "bad_response"} + if r.get("reason"): # an error envelope from _nav_api + return r + return {"ok": True, "map": r.get("map"), "places": r.get("places", [])} + if name == "where_am_i": + r = await asyncio.to_thread(_nav_api, "GET", "/api/nav/active", None) + if not isinstance(r, dict): + return {"ok": False, "reason": "bad_response"} + if r.get("reason"): + return r + return {"ok": True, "map": r.get("map"), + "mode": r.get("mode_label"), + "ready": bool(r.get("bringup_alive")), + "localized": bool(r.get("localizing")), + "places": r.get("places", [])} + if name == "stop_navigation": + return await asyncio.to_thread(_nav_api, "POST", "/api/nav/cancel", None) + return {"ok": False, "reason": "unknown_tool"} + except Exception as exc: + log.warning("tool %s error: %s", name, exc) + return {"ok": False, "reason": "error", "detail": str(exc)[:200]} + # ─── movement-state announcer (N2) ──────────────────── # Spoken confirmation when the operator enables / disables Gemini-driven # locomotion from the dashboard. The actual movement dispatch loop lives @@ -926,7 +1252,7 @@ class GeminiBrain: # rest of the session can react WITHOUT a Gemini reconnect. async def _recognition_state_watcher(self, session: Any) -> None: - last_mtime = 0.0 + last_mtime_ns = -1 last_state = _recog_state.RecognitionState( vision_enabled=self._vision_enabled, face_rec_enabled=self._face_rec_enabled, @@ -934,6 +1260,7 @@ class GeminiBrain: zone_rec_enabled=self._zone_rec_enabled, zones_version=self._zones_version_primed, movement_enabled=self._movement_enabled, + record_enabled=self._record_enabled, ) # Best-effort initial primer if face_rec is already on at session start. if self._face_rec_enabled and self._vision_enabled: @@ -987,9 +1314,15 @@ class GeminiBrain: continue except Exception: continue - if st.st_mtime == last_mtime: + # Use nanosecond mtime: write() does os.replace of a fresh + # tempfile, so two CRUD ops within one coarse mtime tick would + # share an identical whole-second st_mtime and the second change + # would be skipped on this tick. st_mtime_ns has far finer + # resolution, so a rapid second write is observed. (The + # version-diff logic below is still the ultimate safety net.) + if st.st_mtime_ns == last_mtime_ns: continue - last_mtime = st.st_mtime + last_mtime_ns = st.st_mtime_ns new_state = _recog_state.read(_RECOG_STATE_PATH) # Vision toggle — instant. Announce it out loud so Gemini reacts @@ -1074,6 +1407,15 @@ class GeminiBrain: session, self._movement_enabled, is_toggle=True, ) + # Auto-record toggle — flip the recorder live (no session restart). + if new_state.record_enabled != last_state.record_enabled: + self._record_enabled = new_state.record_enabled + try: + self._recorder.enabled = self._record_enabled + except Exception: + pass + log.info("auto-record toggled → %s", self._record_enabled) + last_state = new_state # ─── camera frame send loop ─────────────────────────── diff --git a/gemini/subprocess.py b/gemini/subprocess.py index 7ed8060..76d8c04 100644 --- a/gemini/subprocess.py +++ b/gemini/subprocess.py @@ -88,6 +88,10 @@ class GeminiSubprocess: def __init__(self): self._lock = threading.Lock() self.process: subprocess.Popen | None = None + # Set under _lock for the duration of start()'s heavy Popen so a + # concurrent start() observes it and bails (prevents a double-spawn + # race where two children both grab the G1 mic/speaker). + self._starting = False self.log_tail: deque[str] = deque(maxlen=LOG_TAIL_SIZE) self.user_transcript: deque[str] = deque(maxlen=TRANSCRIPT_TAIL_SIZE) # Gemini's OWN spoken text (output transcription). The movement @@ -107,6 +111,11 @@ class GeminiSubprocess: # slow callback (e.g. movement dispatch reading state) never stalls the # reader thread or blocks log parsing. self._pending_bot: str | None = None + # Lip-sync: callbacks fired on each [[MOUTH:n]] marker (mouth level 0..3) + # emitted by gemini/script.py while Gemini speaks. The LED-mask face + # subsystem registers here to drive the animated mouth. Fired on the + # reader thread; callbacks must be cheap / non-blocking. + self._mouth_callbacks: list = [] self._reader_thread: threading.Thread | None = None self._log_file = None # opened per-session in _reader_loop self.state = "stopped" @@ -144,6 +153,12 @@ class GeminiSubprocess: if callback not in self._bot_callbacks: self._bot_callbacks.append(callback) + def register_mouth_callback(self, callback) -> None: + """Register a fn(level:int 0..3) fired on each [[MOUTH:n]] lip-sync + marker. Used by the LED-mask face. Cheap/non-blocking only.""" + if callback not in self._mouth_callbacks: + self._mouth_callbacks.append(callback) + def attach_camera(self, camera) -> None: """Give the supervisor a reference to the CameraDaemon so it can forward frames to the child over stdin while a session runs.""" @@ -216,7 +231,12 @@ class GeminiSubprocess: self._set_state("listening", "Listening for speech.") elif "session error" in line or "client recreation failed" in line: self._set_state("error", line) - elif "server going away" in line or "session ended" in line or "session dead" in line: + elif ("server going away" in line or "ended — reconnecting" in line + or "ended - reconnecting" in line or "session dead" in line): + # NOTE: keep in lock-step with the brain's emit + # log.info("session #%d ended — reconnecting in 1s", ...) — the + # "#N" between "session" and "ended" means a plain "session ended" + # substring never matched, so we anchor on "ended — reconnecting". self._set_state("warning", line) elif "keyboard interrupt" in line or "cancelled — stopping" in line: self._set_state("stopped", line) @@ -234,6 +254,20 @@ class GeminiSubprocess: clean = line.rstrip() if not clean: continue + # High-frequency lip-sync marker [[MOUTH:n]] — fire callbacks and + # skip it entirely (not logged/tailed, ~10/s) before anything else. + _mi = clean.find("[[MOUTH:") + if _mi != -1: + try: + level = int(clean[_mi + 8:clean.index("]]", _mi)]) + except Exception: + level = 0 + for cb in self._mouth_callbacks: + try: + cb(max(0, min(3, level))) + except Exception: + log.exception("mouth callback failed") + continue if fh is not None: try: fh.write(clean + "\n") @@ -278,8 +312,44 @@ class GeminiSubprocess: with self._lock: if self.process is not None and self.process.poll() is None: return {"started": False, "message": "Already running.", "pid": self.process.pid} + if self._starting: + # A concurrent start() (e.g. two rapid POSTs dispatched on + # separate threads) is already mid-Popen. Bail so we don't + # spawn a second child holding the G1 mic/speaker — the first + # start owns the spawn and will publish self.process. + return {"started": False, "message": "Start already in progress."} + # Sentinel held across the unlocked heavy Popen below; the early + # guard above + this flag make the running-check and the eventual + # self.process assignment atomic w.r.t. a concurrent start(). + self._starting = True + # Close the previous child's pipes if it died on its own (crash, + # not via stop()): stop() closes them, but a crash-then-start path + # would otherwise leak its stdin/stdout until Popen.__del__ at GC. + # stdin close is under _stdin_lock (mirrors stop()) so a stray + # motion-state _send_stdin can't race the close mid-write. + if self.process is not None: + with self._stdin_lock: + old_stdin = getattr(self.process, "stdin", None) + if old_stdin is not None: + try: + old_stdin.close() + except Exception: + pass + old_stdout = getattr(self.process, "stdout", None) + if old_stdout is not None: + try: + old_stdout.close() + except Exception: + pass self._set_state("starting", "Starting...") + try: + return self._start_locked() + finally: + with self._lock: + self._starting = False + + def _start_locked(self) -> dict[str, Any]: script = LIVE_SCRIPT if not script.exists(): raise RuntimeError(f"Script not found: {script}") @@ -444,7 +514,8 @@ class GeminiSubprocess: _audio_swap_loop performs the actual mic/speaker rebind. No-op if the process isn't running or stdin is closed.""" pid = (profile_id or "").strip().lower() - if pid not in {"builtin", "anker", "hollyland_builtin"}: + if pid not in {"builtin", "anker", "anker_powerconf", + "hollyland_builtin", "jbl_builtin_mic"}: log.warning("send_profile: ignoring unknown profile %r", profile_id) return payload: dict[str, Any] = {"id": pid} @@ -456,6 +527,12 @@ class GeminiSubprocess: return self._send_stdin(line) + def send_pause(self, paused: bool) -> None: + """Pause/resume the live interaction while a dashboard record plays — + the child feeds Gemini silence + drops its own audio so the record + owns the chest speaker, then resumes. No-op if not running.""" + self._send_stdin("pause:%d\n" % (1 if paused else 0)) + def _audio_watcher(self) -> None: """Background thread — poll pactl for the Anker USB device, signal the child on every plug/unplug edge transition. @@ -479,7 +556,8 @@ class GeminiSubprocess: return try: if not _ad.pactl_available(): - log.warning("audio watcher disabled — pactl not available") + log.info("audio watcher disabled — no PulseAudio (R1 uses DDS " + "chest speaker + UDP mic; this is expected, not an error)") return except Exception: # If pactl_available itself isn't exposed, fall through and try @@ -505,20 +583,50 @@ class GeminiSubprocess: # plug edges and dispatches profile changes to the child. plugged = _ad.detect_plugged_profiles() ids = {p.get("profile", {}).get("id") for p in (plugged or [])} - anker_present = _ANKER_PROFILE_ID in ids - target = "anker" if anker_present else boot_profile - reason = "anker plugged" if anker_present else "anker unplugged" - # Surface which detection path succeeded (Path A vs pactl) - if anker_present: + # Honor the user's SAVED profile selection whenever its device is + # actually plugged in — covers the JBL, Anker, Hollyland, or any + # future profile. Without this the watcher only knew the Anker and + # kept reverting every other selection back to the boot profile. + # Fallbacks: legacy Anker auto-detect, then boot profile (builtin + # G1 chest speaker) when no external device is present. + try: + selected = (_ad.load_state() or {}).get("profile_id") + except Exception: + selected = None + + def _via(pid: str) -> str: for p in plugged: - if p.get("profile", {}).get("id") == _ANKER_PROFILE_ID: - via = p.get("source_via", "pactl") - if via != "pactl": - reason += f" via {via}" - break + if p.get("profile", {}).get("id") == pid: + v = p.get("source_via", "pactl") + return f" via {v}" if v != "pactl" else "" + return "" + + if selected and selected != "builtin" and selected in ids: + target = selected + reason = f"{selected} present" + _via(selected) + elif _ANKER_PROFILE_ID in ids: + target = "anker" + reason = "anker plugged" + _via(_ANKER_PROFILE_ID) + else: + target = boot_profile + reason = (f"{selected} unplugged → {boot_profile}" + if selected and selected != "builtin" + else "no external device") if target == self._last_profile_id: - continue # edge-only + # Steady state: re-send the desired profile each poll (a + # cheap idempotent no-op on the child — _audio_swap_loop / + # swap_audio_devices short-circuit when already on target). + # This is the recovery path: if a previous swap exhausted + # its 3 retries (e.g. udev hadn't exposed the Anker yet), + # the child kept the old profile with NO pending retry; an + # edge-only watcher would never re-send the same target, + # leaving parent + child desynced until the next physical + # plug edge. Re-sending lets the child converge on its next + # tick. No log / no refresh_devices on this path (already + # done on the edge) so steady state stays quiet. + self.send_profile(target, reason=reason) + continue prev = self._last_profile_id log.info("audio watcher: %s → %s (%s)", @@ -572,12 +680,23 @@ class GeminiSubprocess: # Close stdin/stdout explicitly — without this each start/stop # cycle leaks FDs (relied on Popen.__del__ which only runs at GC; # a reconnect loop would march the FD count to the OS limit). - for pipe in (getattr(proc, "stdin", None), getattr(proc, "stdout", None)): - if pipe is not None: + # The stdin close is taken under _stdin_lock: the motion-state bus + # handler still writes via _send_stdin from the arm worker thread + # (it is never joined here), so without the lock its `closed` check + # could race this close() and raise mid-write. + with self._stdin_lock: + stdin_pipe = getattr(proc, "stdin", None) + if stdin_pipe is not None: try: - pipe.close() + stdin_pipe.close() except Exception: pass + stdout_pipe = getattr(proc, "stdout", None) + if stdout_pipe is not None: + try: + stdout_pipe.close() + except Exception: + pass with self._lock: self.process = None diff --git a/main.py b/main.py index e700b20..43dd4ba 100644 --- a/main.py +++ b/main.py @@ -33,6 +33,14 @@ if _PARENT.name == "Project": _ROOT = _PARENT.parent if str(_ROOT) not in sys.path: sys.path.insert(0, str(_ROOT)) + # This codebase imports itself as `Project.Sanad.*`. If this folder is a copy + # under a different name (e.g. Sanadv3), alias Project.Sanad → THIS package so + # it imports its OWN modules, not the sibling Project/Sanad. (The original + # Sanad folder is unaffected — this only triggers for renamed copies.) + if _THIS_DIR.name != "Sanad" and "Project.Sanad" not in sys.modules: + _self_pkg = importlib.import_module(f"Project.{_THIS_DIR.name}") + sys.modules["Project.Sanad"] = _self_pkg + sys.modules["Project"].Sanad = _self_pkg # type: ignore[attr-defined] else: # Deployed layout — create a virtual Project package and alias if str(_PARENT) not in sys.path: @@ -111,6 +119,7 @@ FaceGallery = _safe_import("FaceGallery", lambda: __import__(" ZoneGallery = _safe_import("ZoneGallery", lambda: __import__("Project.Sanad.vision.zone_gallery", fromlist=["ZoneGallery"]).ZoneGallery) LocoController = _safe_import("LocoController", lambda: __import__("Project.Sanad.G1_Controller.loco_controller", fromlist=["LocoController"]).LocoController) MovementDispatcher = _safe_import("MovementDispatcher", lambda: __import__("Project.Sanad.voice.movement_dispatch", fromlist=["MovementDispatcher"]).MovementDispatcher) +WebNav3Client = _safe_import("WebNav3Client", lambda: __import__("Project.Sanad.navigation", fromlist=["WebNav3Client"]).WebNav3Client) # ── global instances (imported by route modules) ── @@ -118,7 +127,14 @@ MovementDispatcher = _safe_import("MovementDispatcher", lambda: __import__( brain = _safe_construct("brain", Brain) if Brain else None arm = _safe_construct("arm", ArmController) audio_mgr = _safe_construct("audio_mgr", AudioManager) -voice_client = _safe_construct("voice_client", GeminiVoiceClient) +# The voice_client speaks TYPED text (typed-replay + /api/voice/generate), so it +# uses the multilingual verbatim TTS prompt — NOT the Khaleeji persona, which +# forced every language to Arabic. (The live conversation uses live_sub, not +# this client; live_voice only reads its connection flag.) +def _build_voice_client(): + from Project.Sanad.gemini.client import TTS_SYSTEM_PROMPT + return GeminiVoiceClient(system_prompt=TTS_SYSTEM_PROMPT) +voice_client = _safe_construct("voice_client", _build_voice_client if GeminiVoiceClient else None) local_tts = _safe_construct("local_tts", LocalTTSEngine) wake_mgr = _safe_construct("wake_mgr", WakePhraseManager) macro_rec = _safe_construct("macro_rec", (lambda: MacroRecorder(arm)) if (MacroRecorder and arm) else None) @@ -158,6 +174,20 @@ if arm is not None and loco_controller is not None: except Exception: log.exception("Could not wire arm motion-block") +# The voice→arm path (live_voice_loop) drives the SEPARATE singleton +# motion.sanad_arm_controller.ARM, not the `arm` instance above. Wire the SAME +# locomotion interlock onto it so a spoken gesture can't move the arms while +# the robot is (or just was) walking — otherwise the motion-block above would +# only cover the dashboard/Gemini-replay path, not voice triggers. +if loco_controller is not None: + try: + from Project.Sanad.motion.sanad_arm_controller import ARM as _sanad_arm + if hasattr(_sanad_arm, "set_motion_block"): + _sanad_arm.set_motion_block(loco_controller.movement_active) + log.info("Voice arm (sanad_arm) motion-block wired to locomotion movement_active") + except Exception: + log.exception("Could not wire sanad_arm motion-block") + # ── Gemini voice → movement dispatcher (N2 Phase 3) ───────────────────────── # Reads Gemini's spoken (BOT) transcript via the live supervisor's bot-callback # and drives loco_controller on a confirmation-phrase match (Marcus pattern). @@ -181,6 +211,27 @@ if MovementDispatcher and loco_controller is not None: except Exception: log.exception("Could not wire movement dispatcher") +# ── Navigation (web_nav3 Nav2 stack) — thin HTTP client ───────────────────── +# Loosely-coupled client to the standalone web_nav3 service (FastAPI :8765 + +# rosbridge :9090). Owns NO ROS2/Nav2 code; if web_nav3 is down the nav routes +# degrade gracefully. The dashboard "Navigation" tab routes (dashboard/routes/ +# navigation.py) build their own module-level client, so this singleton is the +# parent-side handle used by voice/movement wiring and the subsystem report. +# Config precedence (highest first): env var → dashboard config 'navigation' +# section → hardcoded default — same resolution as the navigation route. +def _build_nav_client(): + from Project.Sanad.core.config_loader import section as _cfg_section + nav_cfg = _cfg_section("dashboard", "navigation") + base_url = (os.environ.get("WEB_NAV3_URL") + or nav_cfg.get("web_nav3_url") + or "http://127.0.0.1:8765") + robot = (os.environ.get("SANAD_ROBOT_NAME") + or nav_cfg.get("robot") + or "sanad") + return WebNav3Client(base_url=str(base_url), robot=str(robot)) + +nav_client = _safe_construct("nav_client", _build_nav_client if WebNav3Client else None) + # ── Recognition (camera + face gallery) ───────────────────────────────────── # Camera is idle until the dashboard toggles vision on; face gallery is pure # file IO and always available if the import succeeded. @@ -346,6 +397,7 @@ SUBSYSTEMS = { "zone_gallery": zone_gallery, "loco_controller": loco_controller, "movement_dispatch": movement_dispatch, + "nav_client": nav_client, } # Critical subsystems — if any of these are None, log a warning at startup @@ -362,17 +414,63 @@ if _missing: log.warning("Subsystems unavailable (%d): %s", len(_missing), ", ".join(_missing)) -_already_shut_down = False +_shutting_down = False + + +def _call_with_timeout(label: str, fn, timeout_s: float = 2.0): + """Run a possibly-blocking teardown call on a daemon thread and never + wait more than ``timeout_s`` for it. If it hangs we log and move on — + the daemon thread dies with the process at os._exit / interpreter exit. + """ + import threading + + def _runner(): + try: + fn() + except Exception: + log.exception("%s failed", label) + + t = threading.Thread(target=_runner, name=f"shutdown-{label}", daemon=True) + t.start() + t.join(timeout_s) + if t.is_alive(): + log.warning("%s did not finish within %.1fs — skipping (forced exit)", + label, timeout_s) def _do_shutdown(from_signal: bool = False): - """Clean shutdown — release hardware, stop background tasks. Idempotent.""" - global _already_shut_down - if _already_shut_down: + """Clean shutdown — release hardware, stop background tasks. Idempotent. + + Never blocks more than a couple seconds on any single step: the loco + StopMove is run on a watchdog thread (it can re-init / hang DDS during + teardown), and tracked children are stopped early so Ctrl+C kills the + whole tree fast. + """ + global _shutting_down + if _shutting_down: return - _already_shut_down = True + _shutting_down = True log.info("Shutdown requested") + # ── Stop tracked child subprocesses FIRST ─────────────────────────── + # The Gemini/local voice supervisor owns a real child OS process (and + # forwards camera/audio to it). Kill it early so on Ctrl+C the child + # tree dies fast even if a later step hangs. + if live_sub is not None: + try: + running = live_sub.is_running() if callable(getattr(live_sub, "is_running", None)) else False + if running: + live_sub.stop() + except Exception: + log.exception("live_sub.stop() failed") + + if camera is not None: + try: + if camera.is_running(): + camera.stop() + except Exception: + log.exception("camera.stop() failed") + if arm is not None: try: if hasattr(arm, "cancel"): @@ -391,19 +489,27 @@ def _do_shutdown(from_signal: bool = False): except Exception: log.exception("movement_dispatch.stop() failed") + # ── Loco stop — NON-BLOCKING ───────────────────────────────────────── + # loco_controller.shutdown() does StopMove + disarm, but StopMove can + # re-init / block on DDS during interpreter teardown. Only stop if a + # client is actually live, and never wait more than ~2s on it. if loco_controller is not None: + _loco_has_client = True try: - loco_controller.shutdown() # StopMove (no FSM change) + disarm + # If the controller exposes a "client exists" probe, honour it so + # we never trigger a lazy LocoClient re-init during teardown. + for _attr in ("has_client", "is_armed", "_client"): + if hasattr(loco_controller, _attr): + _probe = getattr(loco_controller, _attr) + _loco_has_client = bool(_probe() if callable(_probe) else _probe) + break except Exception: - log.exception("loco_controller.shutdown() failed") - - if live_sub is not None: - try: - running = live_sub.is_running() if callable(getattr(live_sub, "is_running", None)) else False - if running: - live_sub.stop() - except Exception: - log.exception("live_sub.stop() failed") + _loco_has_client = True # probe failed — fall back to attempting it + if _loco_has_client: + _call_with_timeout("loco_controller.shutdown()", + loco_controller.shutdown, timeout_s=2.0) + else: + log.info("loco_controller has no live client — skipping StopMove") if audio_mgr is not None: try: @@ -412,22 +518,49 @@ def _do_shutdown(from_signal: bool = False): except Exception: log.exception("audio_mgr.close() failed") - if camera is not None: - try: - if camera.is_running(): - camera.stop() - except Exception: - log.exception("camera.stop() failed") - log.info("Shutdown complete") import atexit # noqa: E402 atexit.register(_do_shutdown) -# NOTE: Do NOT install custom SIGINT/SIGTERM handlers here. -# Uvicorn installs its own signal handlers for graceful shutdown. -# If we override them, Ctrl+C never reaches uvicorn and the server -# keeps running forever. Our _do_shutdown runs via atexit instead. +# atexit is the fallback path (clean uvicorn return / interpreter exit). +# The PRIMARY Ctrl+C path is the explicit SIGINT/SIGTERM handler installed +# in main() — see _install_signal_handlers(). That handler fully takes over: +# it runs the (idempotent, non-blocking) shutdown and then os._exit(0), so it +# never returns to uvicorn. This avoids the old problem where uvicorn's own +# handler and ours would fight — we just don't hand control back. A single +# SIGINT therefore tears down every child and force-exits within ~2s. + + +def _install_signal_handlers(): + """Take over SIGINT/SIGTERM so one Ctrl+C kills EVERYTHING fast. + + We do NOT chain to uvicorn's handler: we stop tracked children + do a + non-blocking loco stop, then os._exit(0) so the process dies immediately + without ever returning to uvicorn or hanging in atexit. + """ + import signal + + def _handler(signum, _frame): + try: + log.warning("force shutdown (signal %s) — killing everything", signum) + except Exception: + pass + try: + _do_shutdown(from_signal=True) + except Exception: + try: + log.exception("_do_shutdown raised during signal teardown") + except Exception: + pass + # Hard-exit so even if uvicorn/atexit would hang we are gone. + os._exit(0) + + for _sig in (signal.SIGINT, signal.SIGTERM): + try: + signal.signal(_sig, _handler) + except Exception: + log.exception("Could not install handler for signal %s", _sig) def _print_env_diagnostic(): @@ -485,6 +618,11 @@ def main(): _print_env_diagnostic() return + # Install our SIGINT/SIGTERM handler EARLY — before any hardware init or + # uvicorn.run() — so a single Ctrl+C at any point forces a fast, clean + # teardown of every child and exits the process. + _install_signal_handlers() + log.info("Sanad starting — Python %s @ %s", sys.version.split()[0], sys.executable) log.info("BASE_DIR: %s", _THIS_DIR) log.info("Dashboard interface: %s → bound to %s", DASHBOARD_INTERFACE, args.host) diff --git a/motion/arm_controller.py b/motion/arm_controller.py index a996600..fc01748 100644 --- a/motion/arm_controller.py +++ b/motion/arm_controller.py @@ -39,7 +39,6 @@ from Project.Sanad.config import ENABLE_ARM_SDK_INDEX RAMP_IN_STEPS = _AC.get("ramp_in_steps", 60) # ~1.0s smooth move to start pose RAMP_OUT_STEPS = _AC.get("ramp_out_steps", 180) # ~3.0s smooth return to home SETTLE_HOLD_SEC = _AC.get("settle_hold_sec", 0.5) # hold start pose before replay begins -WATCHDOG_TIMEOUT = _AC.get("watchdog_timeout_sec", 0.25) # hold last pose if state stale WATCHDOG_DISABLE_AFTER = _AC.get("watchdog_disable_after_sec", 1.0) # abort if state stale this long ARM_INDICES = range( _AC.get("arm_indices_start", 15), @@ -60,6 +59,12 @@ try: ) from unitree_sdk2py.idl.unitree_hg.msg.dds_ import LowCmd_, LowState_ from unitree_sdk2py.utils.crc import CRC + # Battery (BMS) lives in a SEPARATE topic on the G1 — LowState_ (hg) has no + # battery field. Optional: never let its absence break arm import/motion. + try: + from unitree_sdk2py.idl.unitree_hg.msg.dds_ import BmsState_ + except Exception: + BmsState_ = None # IDL factory — LowCmd_() with no args fails because the dataclass # has 5 required positional fields. The SDK ships a default factory @@ -221,6 +226,10 @@ class ArmController: # temperature dashboard). Each entry: {motor_id, surface, winding}. self._current_temps: list[dict[str, Any]] = [] self._last_temp_time = 0.0 + # Battery (BMS) snapshot from rt/lf/bmsstate — separate topic on the G1. + self._bms = None # latest parsed battery dict, or None + self._last_bms_time = 0.0 + self._bms_sub = None # Cached motion file metadata self._motion_files_cache: dict[str, dict[str, Any]] = {} @@ -239,6 +248,15 @@ class ArmController: self._arm_pub.Init() self._state_sub = ChannelSubscriber("rt/lowstate", LowState_) self._state_sub.Init(self._on_low_state, 10) + # Battery: subscribe to the BMS topic (rt/lf/bmsstate, BmsState_). + # Read-only + best-effort — never let a BMS hiccup break arm init. + try: + if BmsState_ is not None: + self._bms_sub = ChannelSubscriber("rt/lf/bmsstate", BmsState_) + self._bms_sub.Init(self._on_bms_state, 10) + log.info("BMS subscriber up (rt/lf/bmsstate)") + except Exception as exc: + log.warning("BMS subscriber init failed (battery unavailable): %s", exc) # IDL types need the SDK's default factory — bare LowCmd_() fails # because the dataclass has 5 required positional fields. if _make_low_cmd is not None: @@ -303,7 +321,13 @@ class ArmController: # full callback rate for the replay watchdog). if (now - self._last_temp_time) >= _TEMP_SAMPLE_INTERVAL_S: temps = [] - for i in range(G1_NUM_MOTOR): + # R1's 26 joints sit in non-contiguous rt/lowstate slots (head at + # 29/30), so sample the explicit slot list rather than range(29) — + # otherwise the head is never read and the arms are mislabelled. + from Project.Sanad.dashboard.temp_motor_map import R1_MOTOR_SLOTS + for i in R1_MOTOR_SLOTS: + if i >= len(msg.motor_state): + continue ms = msg.motor_state[i] surface = winding = None t = getattr(ms, "temperature", None) @@ -345,6 +369,66 @@ class ArmController: with self._state_lock: return list(self._current_temps) + def _on_bms_state(self, msg): + """Battery (BMS) callback — parse the bits the dashboard shows. Wrapped + so a malformed message can never disturb the arm.""" + try: + soc = int(getattr(msg, "soc", 0) or 0) + cur = int(getattr(msg, "current", 0) or 0) # G1 reports mA + # Pack voltage: prefer bmsvoltage[0] (mV); else sum of cell voltages. + volt_mv = 0 + bv = getattr(msg, "bmsvoltage", None) + try: + if bv is not None and len(bv) and int(bv[0]): + volt_mv = int(bv[0]) + except Exception: + volt_mv = 0 + if not volt_mv: + cv = getattr(msg, "cell_vol", None) + if cv is not None: + try: + volt_mv = int(sum(int(x) for x in cv if x)) + except Exception: + volt_mv = 0 + # Max plausible cell/pack temperature (int16 °C). + temp_c = None + tt = getattr(msg, "temperature", None) + if tt is not None: + try: + vals = [int(x) for x in tt if -40 <= int(x) <= 150] + if vals: + temp_c = max(vals) + except Exception: + temp_c = None + batt = { + "soc": max(0, min(100, soc)), + "soh": int(getattr(msg, "soh", 0) or 0), + "current_a": round(cur / 1000.0, 2), + "voltage_v": round(volt_mv / 1000.0, 1) if volt_mv else None, + "temp_c": temp_c, + "cycle": int(getattr(msg, "cycle", 0) or 0), + } + with self._state_lock: + self._bms = batt + self._last_bms_time = time.monotonic() + except Exception: + pass + + def get_battery(self) -> dict[str, Any]: + """Latest battery snapshot for the dashboard. `available=False` until the + first BmsState_ arrives (or if the BMS topic isn't published).""" + with self._state_lock: + b = dict(self._bms) if self._bms else None + age = (time.monotonic() - self._last_bms_time) if self._last_bms_time else None + if b is None: + return {"available": False} + cur = b.get("current_a") or 0.0 + b["status"] = ("charging" if cur > 0.05 + else "discharging" if cur < -0.05 else "idle") + b["available"] = True + b["age_sec"] = round(age, 1) if age is not None else None + return b + def send_frame(self, arm_target_q: list[float], body_lock_q: list[float]): """Public single-frame send. Use only inside a controlled playback loop.""" self._send_frame(arm_target_q, body_lock_q) diff --git a/motion/macro_player.py b/motion/macro_player.py index 0a21a17..126271e 100644 --- a/motion/macro_player.py +++ b/motion/macro_player.py @@ -77,6 +77,21 @@ class _ArmAdapter: # No watchdog support: pretend state is fresh return 0.0 + def is_blocked(self) -> bool: + """True while the arm's locomotion interlock forbids arm motion. + + Mirrors the arm controller's own _blocked()/set_motion_block() gate so + a macro started before locomotion was armed stops mid-playback if the + legs are armed afterwards. If the arm has no such predicate, never + blocks (preserves behaviour for controllers without the interlock).""" + fn = getattr(self._arm, "_blocked", None) + if callable(fn): + try: + return bool(fn()) + except Exception: + return False + return False + class MacroPlayer: def __init__(self, audio_manager=None, arm_controller=None): @@ -218,6 +233,9 @@ class MacroPlayer: for step in range(steps): if self._stop_event.is_set(): return False + if adapter.is_blocked(): + log.error("MacroPlayer %s abort — locomotion armed mid-playback", label) + return False t = (step + 1) / steps interp = _lerp_q(from_q, to_q, t) try: @@ -237,6 +255,10 @@ class MacroPlayer: if self._stop_event.is_set(): break + if adapter.is_blocked(): + log.error("MacroPlayer abort — locomotion armed mid-playback") + break + age = adapter.state_age() if age > WATCHDOG_DISABLE_AFTER: log.error("MacroPlayer watchdog abort — state stale %.2fs", age) diff --git a/motion/macro_recorder.py b/motion/macro_recorder.py index 9697ece..da2c4c9 100644 --- a/motion/macro_recorder.py +++ b/motion/macro_recorder.py @@ -10,6 +10,8 @@ These can be replayed in sync via MacroPlayer. from __future__ import annotations import json +import os +import tempfile import threading import time import wave @@ -81,27 +83,49 @@ class MacroRecorder: if self._motion_thread: self._motion_thread.join(timeout=3.0) - # Save audio audio_path = AUDIO_RECORDINGS_DIR / f"{self._name}.wav" - pcm = b"".join(self._audio_frames) - with wave.open(str(audio_path), "wb") as wf: - wf.setnchannels(CHANNELS) - wf.setsampwidth(2) # int16 - wf.setframerate(RECEIVE_SAMPLE_RATE) - wf.writeframes(pcm) - - # Save motion motion_path = MOTION_RECORDINGS_DIR / f"{self._name}.jsonl" - with open(motion_path, "w") as f: - f.write(json.dumps({"meta": {"hz": REPLAY_HZ, "motors": 29}}) + "\n") + + # A failed write must NOT leave _recording=True forever (which would + # wedge every future start()). Clear the busy flag in finally no matter + # what; write both files atomically (tempfile + os.replace) so a partial + # write can't surface a corrupt recording. + try: + # Save audio + pcm = b"".join(self._audio_frames) + tmp_audio = f"{audio_path}.tmp" + with wave.open(tmp_audio, "wb") as wf: + wf.setnchannels(CHANNELS) + wf.setsampwidth(2) # int16 + wf.setframerate(RECEIVE_SAMPLE_RATE) + wf.writeframes(pcm) + os.replace(tmp_audio, str(audio_path)) + + # Save motion + content_lines = [json.dumps({"meta": {"hz": REPLAY_HZ, "motors": 29}})] for frame in self._motion_frames: - f.write(json.dumps(frame) + "\n") + content_lines.append(json.dumps(frame)) + content = ("\n".join(content_lines) + "\n").encode("utf-8") + fd, tmp_motion = tempfile.mkstemp( + prefix=f".{motion_path.name}.", suffix=".tmp", + dir=str(motion_path.parent), + ) + try: + with os.fdopen(fd, "wb") as f: + f.write(content) + os.replace(tmp_motion, str(motion_path)) + except Exception: + try: + os.unlink(tmp_motion) + except OSError: + pass + raise + finally: + with self._lock: + self._recording = False duration = time.monotonic() - self._started_at - with self._lock: - self._recording = False - log.info("Macro saved: audio=%s motion=%s (%.1fs)", audio_path, motion_path, duration) return { "recording": False, @@ -138,6 +162,17 @@ class MacroRecorder: def _record_motion(self): """Capture joint positions at REPLAY_HZ.""" interval = 1.0 / REPLAY_HZ + + # Wait for the first LowState before sampling real hardware, otherwise + # _get_current_q() returns the seed [0.0]*29 and we capture an all-zero + # macro that body-locks the arms to zero on replay. Only relevant when a + # live, initialized arm is present (sim path records zeros by design). + if self._arm is not None and getattr(self._arm, "_initialized", False): + wait = getattr(self._arm, "wait_for_state", None) + if callable(wait) and not wait(timeout=2.0): + log.error("Macro motion aborted — no LowState received in 2s") + return + t0 = time.monotonic() while not self._stop_event.is_set(): t = round(time.monotonic() - t0, 4) diff --git a/motion/sanad_arm_controller.py b/motion/sanad_arm_controller.py index ed8e168..82a208c 100644 --- a/motion/sanad_arm_controller.py +++ b/motion/sanad_arm_controller.py @@ -92,7 +92,16 @@ except Exception: # G1 hardware constants — single source in config/core_config.json from Project.Sanad.config import G1_NUM_MOTOR, ENABLE_ARM_SDK_INDEX, REPLAY_HZ -DATA_DIR = BASE_DIR / _MCFG["data_subdir"] +# JSONL replay source. The legacy default (BASE_DIR/DataG1) does NOT exist on +# this deployment — the recorded/taught motions actually live in the canonical +# MOTIONS_DIR (data/motions), the same store every other module reads/writes. +# Prefer it so a file-backed action reads the real files; fall back to the +# legacy path only if the canonical one can't be imported. +try: + from Project.Sanad.config import MOTIONS_DIR as _MOTIONS_DIR + DATA_DIR = Path(_MOTIONS_DIR) +except Exception: + DATA_DIR = BASE_DIR / _MCFG["data_subdir"] ACTION_COOLDOWN_SEC = _MCFG["action_cooldown_sec"] STABILITY_THRESHOLD = _MCFG["stability_threshold"] @@ -117,6 +126,29 @@ class ArmController: self._client = None self._is_busy = False self._init_lock = threading.Lock() + # Guards the check-and-set of _is_busy so two near-simultaneous + # triggers can't both pass the `if self._is_busy` gate and launch + # two overlapping replays on rt/arm_sdk. + self._busy_lock = threading.Lock() + # Arm ⇄ locomotion interlock. While this predicate returns True the + # robot may be walking, so NO arm action may fire (matches the + # motion-block wired onto motion/arm_controller.ArmController in + # main.py). Installed via set_motion_block(); None = no block. + self._motion_block = None + + def set_motion_block(self, predicate): + """Install a no-args predicate; while it returns True every arm + trigger refuses to run (arm ⇄ locomotion mutual exclusion).""" + self._motion_block = predicate + + def _blocked(self) -> bool: + pred = self._motion_block + if pred is None: + return False + try: + return bool(pred()) + except Exception: + return False def init(self) -> bool: with self._init_lock: @@ -125,7 +157,22 @@ class ArmController: if ChannelFactoryInitialize is None: return False try: - ChannelFactoryInitialize(0) + # Single-DDS-init rule: the process-wide ChannelFactory is + # owned by motion/arm_controller.ArmController (main.arm), + # initialised on the user-selected NIC in main.arm.init(). + # Only init here if that has NOT happened yet — calling + # ChannelFactoryInitialize a second time would re-init on the + # default interface and is exactly the conflict we must avoid. + _factory_ready = False + try: + from Project.Sanad import main as _sanad_main + _factory_ready = bool( + getattr(getattr(_sanad_main, "arm", None), + "_initialized", False)) + except Exception: + _factory_ready = False + if not _factory_ready: + ChannelFactoryInitialize(0) self._pub = ChannelPublisher("rt/arm_sdk", LowCmd_) self._pub.Init() self._sub = ChannelSubscriber("rt/lowstate", LowState_) @@ -243,7 +290,8 @@ class ArmController: self._pub.Write(cmd) time.sleep(0.01) - self._is_busy = False + with self._busy_lock: + self._is_busy = False self._last_action_time = time.time() print("🔓 Ready.") @@ -254,26 +302,36 @@ class ArmController: self._client.ExecuteAction(action_map.get(action_name)) time.sleep(3.5) finally: - self._is_busy = False + with self._busy_lock: + self._is_busy = False self._last_action_time = time.time() print("🔓 Ready.") def trigger_action_by_id(self, action_id: int): - if not self.init(): + # Arm ⇄ locomotion interlock — never drive the arms while the robot + # may be walking (manual armed / teleop / recent move/step). + if self._blocked(): return - if self._is_busy: + if not self.init(): return if not self._cooldown_ok(): return opt = OPTION_BY_ID.get(int(action_id)) - if opt: + if not opt: + return + # Atomic check-and-set so two near-simultaneous triggers can't both + # launch a replay thread on rt/arm_sdk. + with self._busy_lock: + if self._is_busy: + return self._is_busy = True - if opt.file: - threading.Thread(target=self._managed_replay, args=(opt.file,), daemon=True).start() - elif self._client and opt.name in action_map: - threading.Thread(target=self._managed_sdk_action, args=(opt.name,), daemon=True).start() - else: + if opt.file: + threading.Thread(target=self._managed_replay, args=(opt.file,), daemon=True).start() + elif self._client and opt.name in action_map: + threading.Thread(target=self._managed_sdk_action, args=(opt.name,), daemon=True).start() + else: + with self._busy_lock: self._is_busy = False def trigger_action_by_name(self, action_name: str): diff --git a/motion/teaching.py b/motion/teaching.py index 9cfa82a..16738d7 100644 --- a/motion/teaching.py +++ b/motion/teaching.py @@ -20,7 +20,7 @@ import time from pathlib import Path from typing import Any -from Project.Sanad.config import G1_NUM_MOTOR, MOTIONS_DIR, REPLAY_HZ +from Project.Sanad.config import ENABLE_ARM_SDK_INDEX, G1_NUM_MOTOR, MOTIONS_DIR, REPLAY_HZ from Project.Sanad.core.config_loader import section as _cfg_section from Project.Sanad.core.event_bus import bus from Project.Sanad.core.logger import get_logger @@ -139,7 +139,7 @@ class TeachingSession: else: # arms low_cmd.motor_cmd[i].kp = HOLD_ARM_KP low_cmd.motor_cmd[i].kd = HOLD_ARM_KD - low_cmd.motor_cmd[29].q = 1.0 + low_cmd.motor_cmd[ENABLE_ARM_SDK_INDEX].q = 1.0 low_cmd.crc = crc.Crc(low_cmd) arm._arm_pub.Write(low_cmd) time.sleep(interval) @@ -171,7 +171,7 @@ class TeachingSession: low_cmd.motor_cmd[i].q = current_q[i] low_cmd.motor_cmd[i].kp = TEACH_ARM_KP low_cmd.motor_cmd[i].kd = TEACH_ARM_KD - low_cmd.motor_cmd[29].q = 1.0 + low_cmd.motor_cmd[ENABLE_ARM_SDK_INDEX].q = 1.0 low_cmd.crc = crc.Crc(low_cmd) arm._arm_pub.Write(low_cmd) diff --git a/navigation/README.md b/navigation/README.md new file mode 100644 index 0000000..6dc0f4e --- /dev/null +++ b/navigation/README.md @@ -0,0 +1,85 @@ +# SanadV3 Navigation + +Thin HTTP **client** to the external `web_nav3` Nav2 stack. This module owns +**no** ROS2/Nav2 code — it lets SanadV3 (dashboard + voice) drive autonomous +navigation over plain HTTP. If `web_nav3` is down, nav features degrade +gracefully and the rest of SanadV3 keeps running. + +## What this module does + +- `web_nav3_client.py` — `WebNav3Client`, a loose-coupled `requests` client. + **By contract no method ever raises into the caller**: each returns + `{"ok": bool, "error": str|None, ...}` or a `NavStatus`. If `web_nav3` is + unreachable, callers get a clean failure result instead of an exception. +- `NavStatus` — health snapshot from `GET /api/status` (`bringup_alive`, + `rosbridge_alive`, `reachable`, `log_tail`). + +## Architecture + +``` +SanadV3 dashboard (:8000) ─┐ + Navigation tab │ HTTP ┌── Nav2 ──┐ + ├──────────────▶│ web_nav3 │──▶ cmd_vel_loco_bridge ──▶ LocoClient (G1 legs) +SanadV3 voice (Gemini) ──┘ (:8765) └──────────┘ + movement_dispatch.py rosbridge :9090 (live map / TF) +``` + +- SanadV3 plane = Python/asyncio, non-ROS. Dashboard on **:8000**. +- `web_nav3` = standalone FastAPI on **:8765** wrapping ROS2 Nav2 + rosbridge + on **:9090**. It owns SLAM, Nav2, and the `cmd_vel_loco_bridge` that drives + the G1 legs via `LocoClient`. + +## Configure + +Connection is resolved with precedence **env var → dashboard config → default**: + +- `WEB_NAV3_URL` (default `http://127.0.0.1:8765`) — the `web_nav3` FastAPI base. +- `ROSBRIDGE_URL` (default `ws://127.0.0.1:9090`) — live map / TF stream. +- `SANAD_ROBOT_NAME` (default `sanad`) — sent as the `X-Robot-Name` header. + +`config.py` exposes `WEB_NAV3_URL`. `main.py` builds the shared `nav_client` +singleton; `dashboard/routes/navigation.py` builds its own module-level client +(both use the same resolution). A broken nav package never blocks the dashboard. + +## Dashboard Navigation tab + +Backend proxy lives under `/api/nav/*` (prefix applied in `dashboard/app.py`). +The "Navigation" SPA tab lists saved **places** and **missions**, sends goto / +cancel, saves the current pose, and embeds the live `web_nav3` map iframe from +the robot at `:8765`. When the client is unavailable, status returns +`{"available": false}` and action endpoints return 503. + +## API endpoints (`/api/nav/*`) + +| Method | Path | Action | +|--------|------------------|------------------------------------------| +| GET | `/status` | health; `{available:false}` if degraded | +| GET | `/config` | web_nav3 / rosbridge URLs + robot name | +| GET | `/places` | list saved places | +| POST | `/goto` | navigate to a saved place by `name` | +| POST | `/cancel` | best-effort cancel (stops bringup) | +| POST | `/save_here` | save current pose as a named place | +| GET | `/maps` | list maps | +| GET | `/missions` | list missions | +| POST | `/missions/run` | run a saved mission by `id` | + +## NEXT STEPS + +1. **Voice bridge (not yet wired).** `voice/movement_dispatch.py` currently + drives discrete `loco_controller` steps only. Add a path so destination + phrases ("go to the lobby" / "اذهب إلى الردهة") map to `nav_client.goto()` + instead of stepping. Keep it gated on the existing + `recognition_state.movement_enabled` toggle. + +2. **CRITICAL — LocoClient arbitration (prerequisite, do before #1).** + `web_nav3`'s `cmd_vel_loco_bridge` and SanadV3's `loco_controller` **must + never drive `LocoClient` simultaneously** — two velocity sources to the G1 + legs at once is unsafe. Only ONE may hold the legs. Before enabling + voice-driven autonomous nav, build a hand-off: when a Nav2 goto is active, + `loco_controller` must release / be disarmed, and vice versa (fail-closed). + No `goto()` voice wiring lands until this interlock exists. + +3. **Single DDS participant ordering.** SanadV3 and `web_nav3` share one Unitree + DDS domain on the G1. Initialize the DDS channel factory **exactly once**, + before any consumer. Decide startup order (whoever owns `LocoClient` inits + first) and ensure the other side never re-inits the participant. diff --git a/navigation/__init__.py b/navigation/__init__.py new file mode 100644 index 0000000..e2b4a2d --- /dev/null +++ b/navigation/__init__.py @@ -0,0 +1,10 @@ +"""SanadV3 navigation subsystem — thin client to the web_nav3 Nav2 stack. + +web_nav3 runs as its own service (FastAPI on :8765 + rosbridge on :9090). +This module lets SanadV3 (dashboard + voice) drive it without owning any +ROS2/Nav2 code. Loose coupling: if web_nav3 is down, nav features degrade +gracefully and the rest of SanadV3 is unaffected. +""" +from .web_nav3_client import WebNav3Client, NavStatus + +__all__ = ["WebNav3Client", "NavStatus"] diff --git a/navigation/goal_monitor.py b/navigation/goal_monitor.py new file mode 100644 index 0000000..1856f04 --- /dev/null +++ b/navigation/goal_monitor.py @@ -0,0 +1,352 @@ +"""Nav2 goal-status monitor — arrival/failure feedback for Gemini voice nav. + +web_nav3's ``goto()`` is fire-and-forget: it publishes a goal and returns at +once, with no completion event. To let Gemini tell the user the truth ("we've +arrived" / "I couldn't get there") instead of guessing, we watch the Nav2 +action status over rosbridge and, on a terminal status, push a +``[NAV ARRIVED]`` / ``[NAV FAILED]`` note to the Gemini child (via +``live_sub.send_state``) and release the nav arbiter so the legs free up. + +Design +------ +* One background daemon thread runs an asyncio loop holding a persistent + rosbridge websocket subscription to ``/navigate_to_pose/_action/status`` + (``action_msgs/msg/GoalStatusArray``). +* ``arm_goal(place)`` marks a pending destination. A goal that is ACCEPTED/ + EXECUTING and not already-terminal is latched as "ours"; its terminal status + fires feedback. A CANCELED of the latched goal while another goal is active + is treated as a preemption (re-latch, don't fire). Re-arming supersedes. +* A watchdog fails the goal after ``SANAD_NAV_GOAL_TIMEOUT_S`` so the arbiter + is ALWAYS released even if rosbridge/websockets is unavailable. +* ``request_cancel()`` sends a real rosbridge action CancelGoal (cancel-all) so + "stop" actually stops Nav2 — not just an arbiter release. + +Everything is best-effort: the drive already succeeded by the time we arm, so +any monitor failure simply means no spoken feedback — never a crash. +""" +from __future__ import annotations + +import asyncio +import base64 +import json +import os +import threading +import time +from typing import Any, List, Optional + +from Project.Sanad.core.logger import get_logger + +log = get_logger("goal_monitor") + +# Capture the arbiter at import so releasing the legs never depends on a lazy +# import succeeding inside the fire path (a missed release locks the legs). +try: + from Project.Sanad.dashboard.routes import _arbiter as _ARBITER +except Exception: # noqa: BLE001 + _ARBITER = None + +_ROSBRIDGE_URL = ( + os.environ.get("SANAD_ROSBRIDGE_URL") + or os.environ.get("ROSBRIDGE_URL") + or "ws://127.0.0.1:9090" +) +_STATUS_TOPIC = os.environ.get( + "SANAD_NAV_STATUS_TOPIC", "/navigate_to_pose/_action/status" +) +_STATUS_TYPE = "action_msgs/msg/GoalStatusArray" +_CANCEL_SERVICE = os.environ.get( + "SANAD_NAV_CANCEL_SERVICE", "/navigate_to_pose/_action/cancel_goal" +) +_GOAL_TIMEOUT_S = float(os.environ.get("SANAD_NAV_GOAL_TIMEOUT_S", "240")) + +# action_msgs/msg/GoalStatus.status +_ACCEPTED, _EXECUTING = 1, 2 +_SUCCEEDED, _CANCELED, _ABORTED = 4, 5, 6 +_TERMINAL = {_SUCCEEDED, _CANCELED, _ABORTED} + + +def _uuid_of(status: dict) -> Any: + """Canonical, encoding-independent key for a goal id. + + rosbridge may serialize uint8[16] as a base64 string OR an int list + depending on its png/cbor config; normalize both to a tuple of ints so the + ACCEPTED frame and the terminal frame compare equal even if the bridge + switches representation mid-session.""" + gid = ((status.get("goal_info") or {}).get("goal_id") or {}) + u = gid.get("uuid") + if isinstance(u, str): + try: + return tuple(base64.b64decode(u)) + except Exception: + return u + if isinstance(u, list): + try: + return tuple(int(x) for x in u) + except Exception: + return tuple(u) + return None + + +class _GoalMonitor: + def __init__(self) -> None: + self._lock = threading.Lock() + self._active = False + self._place: Optional[str] = None + self._armed_at = 0.0 + self._latched: Any = None + self._ignore: set = set() # uuids seen terminal — never latch + self._thread: Optional[threading.Thread] = None + + # ── public ─────────────────────────────────────────────── + def arm(self, place: str) -> None: + spawn = False + with self._lock: + self._active = True + self._place = place + self._armed_at = time.time() + self._latched = None + self._ignore = set() + if self._thread is None: + self._thread = threading.Thread( + target=self._run, daemon=True, name="nav-goal-monitor") + t = self._thread + spawn = True + if spawn: + t.start() + log.info("armed goal monitor for '%s'", place) + + def disarm(self) -> None: + with self._lock: + self._active = False + self._latched = None + self._place = None + + # ── thread / loop ──────────────────────────────────────── + def _run(self) -> None: + # Loop so that a new arm() arriving exactly as a session ends keeps the + # monitor alive. The decision to exit is made under the lock together + # with clearing _thread, so arm()'s "spawn only if _thread is None" can + # never strand an active goal with no live thread. + while True: + try: + asyncio.run(self._serve()) + except Exception as exc: # noqa: BLE001 + log.warning("goal monitor loop ended: %s", exc) + with self._lock: + if not self._active: + self._thread = None + return + # still active → a fresh goal arrived; serve again + + async def _serve(self) -> None: + try: + import websockets # local import — dashboard env only + except Exception as exc: # noqa: BLE001 + # No ws client → honor the timeout so the arbiter is still released. + log.warning("websockets unavailable — nav feedback via timeout only: %s", exc) + while True: + await asyncio.sleep(min(5.0, _GOAL_TIMEOUT_S)) + if self._check_timeout(): + return + with self._lock: + if not self._active: + return + while True: + try: + await self._listen(websockets) + except Exception as exc: # noqa: BLE001 + log.debug("rosbridge listen error: %s", exc) + if self._check_timeout(): + return + with self._lock: + if not self._active: + return + await asyncio.sleep(3.0) + + async def _listen(self, websockets: Any) -> None: + async with websockets.connect( + _ROSBRIDGE_URL, ping_interval=20, ping_timeout=20 + ) as ws: + await ws.send(json.dumps({ + "op": "subscribe", "topic": _STATUS_TOPIC, + "type": _STATUS_TYPE, "throttle_rate": 0, "queue_length": 1, + })) + log.info("goal monitor subscribed %s", _STATUS_TOPIC) + while True: + try: + raw = await asyncio.wait_for(ws.recv(), timeout=5.0) + except asyncio.TimeoutError: + if self._check_timeout(): + return + with self._lock: + if not self._active: + return + continue + try: + data = json.loads(raw) + except Exception: + continue + if data.get("op") != "publish": + continue + msg = data.get("msg") or {} + self._on_status(msg.get("status_list") or []) + if self._check_timeout(): + return + with self._lock: + if not self._active: + return + + # ── status handling ────────────────────────────────────── + def _on_status(self, status_list: List[dict]) -> None: + fire: Optional[tuple] = None + with self._lock: + if not self._active: + return + states = {} # uuid -> status (this frame) + for s in status_list: + u = _uuid_of(s) + st = s.get("status") + if u is None: + continue + states[u] = st + if st in _TERMINAL: + self._ignore.add(u) # prior/other goals — never ours + # Latch a genuinely-active, non-ignored goal as ours. + if self._latched is None: + cand = [u for u, st in states.items() + if st in (_ACCEPTED, _EXECUTING) and u not in self._ignore] + if cand: + self._latched = cand[-1] # newest Nav2 entry + # Terminal for the latched goal? + if self._latched is not None: + st = states.get(self._latched) + if st in _TERMINAL: + if st == _CANCELED: + # A CANCELED latch while another goal is active is a + # preemption (a newer goal replaced ours) — re-latch + # instead of falsely reporting we stopped. + others = [u for u, s2 in states.items() + if s2 in (_ACCEPTED, _EXECUTING) + and u not in self._ignore and u != self._latched] + if others: + self._ignore.add(self._latched) + self._latched = others[-1] + st = None + if st is not None and st in _TERMINAL: + fire = (self._place, st) + self._active = False + self._latched = None + self._place = None + if fire: + self._fire(*fire) + + def _check_timeout(self) -> bool: + fire: Optional[tuple] = None + with self._lock: + if self._active and (time.time() - self._armed_at) > _GOAL_TIMEOUT_S: + fire = (self._place, "timeout") + self._active = False + self._latched = None + self._place = None + if fire: + self._fire(*fire) + return True + return False + + # ── feedback + arbiter release ─────────────────────────── + def _fire(self, place: Optional[str], status: Any) -> None: + place = place or "the destination" + if status == _SUCCEEDED: + event = "nav_arrived" + cmd = (f"You have arrived at '{place}'. Briefly tell the user " + "you've arrived, in your normal Khaleeji style.") + elif status == _CANCELED: + event = "nav_canceled" + cmd = (f"Navigation to '{place}' was canceled. Briefly acknowledge " + "it if relevant.") + elif status == "timeout": + event = "nav_failed" + cmd = (f"You could not confirm reaching '{place}' — it is taking " + "too long or the path is blocked. Briefly tell the user you " + "couldn't get there.") + else: # ABORTED / unknown + event = "nav_failed" + cmd = (f"You could NOT reach '{place}' — the path was blocked or " + "planning failed. Briefly apologise and say you couldn't " + "get there.") + log.info("goal terminal: place=%s status=%s → %s", place, status, event) + # Free the legs first (a missed release locks them — make it loud). + arb = _ARBITER + if arb is None: + try: + from Project.Sanad.dashboard.routes import _arbiter as arb + except Exception: # noqa: BLE001 + arb = None + if arb is not None: + try: + arb.release_nav() + except Exception as exc: # noqa: BLE001 + log.error("release_nav failed after %s: %s", event, exc) + else: + log.error("arbiter unavailable — could not release nav after %s", event) + # Tell Gemini (via the supervisor's stdin push to the child). + try: + from Project.Sanad.main import live_sub + if live_sub is not None and hasattr(live_sub, "send_state"): + live_sub.send_state(event, cmd) + except Exception as exc: # noqa: BLE001 + log.debug("nav feedback inject failed: %s", exc) + + +_MON = _GoalMonitor() + + +def arm_goal(place: str) -> None: + """Begin watching for the arrival/failure of a goal driving to ``place``.""" + try: + _MON.arm(place) + except Exception as exc: # noqa: BLE001 + log.debug("arm_goal failed: %s", exc) + + +def disarm() -> None: + """Stop watching the current goal (e.g. on an explicit cancel).""" + try: + _MON.disarm() + except Exception as exc: # noqa: BLE001 + log.debug("disarm failed: %s", exc) + + +async def _cancel_once() -> bool: + try: + import websockets + except Exception: # noqa: BLE001 + return False + try: + async with websockets.connect(_ROSBRIDGE_URL, ping_interval=None) as ws: + # Zero goal_id + zero stamp == cancel ALL goals (CancelGoal convention). + await ws.send(json.dumps({ + "op": "call_service", + "service": _CANCEL_SERVICE, + "type": "action_msgs/srv/CancelGoal", + "args": {"goal_info": {"goal_id": {"uuid": [0] * 16}, + "stamp": {"sec": 0, "nanosec": 0}}}, + })) + try: + await asyncio.wait_for(ws.recv(), timeout=3.0) + except Exception: + pass + return True + except Exception as exc: # noqa: BLE001 + log.debug("cancel ws failed: %s", exc) + return False + + +def request_cancel() -> bool: + """Send a real Nav2 action CancelGoal (cancel-all) over rosbridge so the + robot actually stops. Blocking; call via asyncio.to_thread. Best-effort.""" + try: + return asyncio.run(_cancel_once()) + except Exception as exc: # noqa: BLE001 + log.debug("request_cancel failed: %s", exc) + return False diff --git a/navigation/web_nav3_client.py b/navigation/web_nav3_client.py new file mode 100644 index 0000000..703749b --- /dev/null +++ b/navigation/web_nav3_client.py @@ -0,0 +1,375 @@ +"""Thin HTTP client to the web_nav3 Nav2 stack for SanadV3. + +web_nav3 exposes a FastAPI surface (default http://127.0.0.1:8765) that wraps +a ROS2/Nav2 + rosbridge bringup. SanadV3 talks to it over plain HTTP so it +never has to import ROS2 itself. + +Design contract: NO method here ever raises into the caller. Every public +method returns either a clean dict {"ok": bool, "error": str|None, ...} or a +NavStatus. If web_nav3 is unreachable/down, callers (dashboard, voice) get a +graceful failure result and keep running. +""" +from __future__ import annotations + +import logging +import threading +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +import requests + +logger = logging.getLogger("sanadv3.navigation") + + +@dataclass +class NavStatus: + """Snapshot of web_nav3 health (from GET /api/status).""" + + bringup_alive: bool = False + rosbridge_alive: bool = False + reachable: bool = False + error: Optional[str] = None + log_tail: str = "" + # What the single bringup is doing right now (from /api/status): + # mode 1 fresh / 2 continue / 3 localize / None idle. + mode: Optional[int] = None + active_map: Optional[str] = None + mode_label: str = "IDLE" + mapping: bool = False + localizing: bool = False + + def as_dict(self) -> Dict[str, Any]: + return { + "bringup_alive": self.bringup_alive, + "rosbridge_alive": self.rosbridge_alive, + "reachable": self.reachable, + "error": self.error, + "log_tail": self.log_tail, + "mode": self.mode, + "active_map": self.active_map, + "mode_label": self.mode_label, + "mapping": self.mapping, + "localizing": self.localizing, + } + + +class WebNav3Client: + """Loosely-coupled HTTP client to web_nav3. + + Args: + base_url: web_nav3 FastAPI base, e.g. "http://127.0.0.1:8765". + robot: robot name, sent as X-Robot-Name header on every request. + timeout: default per-request timeout (seconds). + """ + + def __init__( + self, + base_url: str = "http://127.0.0.1:8765", + robot: str = "sanad", + timeout: float = 3.0, + ) -> None: + self.base_url = base_url.rstrip("/") + self.robot = robot + self.timeout = float(timeout) + self._session = requests.Session() + # requests.Session is not guaranteed thread-safe, but route handlers + # call us from asyncio.to_thread workers (multiple tabs / overlapping + # status-poll + goto). Serialize Session access with a lock. + self._session_lock = threading.Lock() + + # ------------------------------------------------------------------ # + # internals + # ------------------------------------------------------------------ # + def _headers(self) -> Dict[str, str]: + return {"X-Robot-Name": self.robot} + + def _url(self, path: str) -> str: + if not path.startswith("/"): + path = "/" + path + return self.base_url + path + + def _request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + ) -> Dict[str, Any]: + """Perform a request, always returning {"ok":bool, ...}; never raises.""" + url = self._url(path) + try: + with self._session_lock: + resp = self._session.request( + method, + url, + params=params, + json=json_body, + headers=self._headers(), + timeout=timeout if timeout is not None else self.timeout, + ) + except requests.RequestException as exc: + logger.debug("web_nav3 %s %s failed: %s", method, path, exc) + return {"ok": False, "error": f"{type(exc).__name__}: {exc}"} + + # Try to decode a JSON body either way (errors often carry detail). + body: Any = None + try: + body = resp.json() + except ValueError: + body = resp.text + + if not resp.ok: + detail = body + if isinstance(body, dict): + detail = body.get("detail") or body.get("error") or body + return { + "ok": False, + "error": f"HTTP {resp.status_code}: {detail}", + "status_code": resp.status_code, + "data": body, + } + + return {"ok": True, "error": None, "data": body, "status_code": resp.status_code} + + # ------------------------------------------------------------------ # + # health + # ------------------------------------------------------------------ # + def reachable(self) -> bool: + """Quick liveness check: short-timeout GET /api/status. + + Reachable means web_nav3 ANSWERED — any HTTP response (including a 5xx + from a running-but-erroring backend) counts. Only a transport failure + (connection refused / timeout) means unreachable. + """ + try: + with self._session_lock: + self._session.get( + self._url("/api/status"), + headers=self._headers(), + timeout=min(self.timeout, 1.5), + ) + return True + except requests.RequestException: + return False + + def status(self) -> NavStatus: + """Poll GET /api/status; returns a NavStatus (never raises).""" + res = self._request("GET", "/api/status") + if not res["ok"]: + # A 'status_code' key means web_nav3 answered with an HTTP error + # (e.g. 500) — it IS reachable, just erroring. Only a transport + # failure (no status_code) is genuinely unreachable. + answered = "status_code" in res + log_tail = "" + body = res.get("data") + if answered and isinstance(body, dict): + log_tail = str(body.get("log_tail", "") or "") + return NavStatus(reachable=answered, error=res["error"], log_tail=log_tail) + data = res.get("data") or {} + if not isinstance(data, dict): + return NavStatus(reachable=True, error="unexpected status payload") + return NavStatus( + bringup_alive=bool(data.get("bringup_alive", False)), + rosbridge_alive=bool(data.get("rosbridge_alive", False)), + reachable=True, + error=None, + log_tail=str(data.get("log_tail", "") or ""), + mode=data.get("mode"), + active_map=data.get("active_map"), + mode_label=str(data.get("mode_label", "IDLE") or "IDLE"), + mapping=bool(data.get("mapping", False)), + localizing=bool(data.get("localizing", False)), + ) + + # ------------------------------------------------------------------ # + # bringup lifecycle + # ------------------------------------------------------------------ # + def start(self, mode: int, db_path: Optional[str] = None) -> Dict[str, Any]: + """POST /api/start. mode 1=fresh SLAM, 2/3=localization (need db_path).""" + body: Dict[str, Any] = {"mode": int(mode), "robot": self.robot} + if db_path is not None: + body["db_path"] = db_path + return self._request("POST", "/api/start", json_body=body, timeout=15.0) + + def stop(self) -> Dict[str, Any]: + """POST /api/stop — SIGINT the running bringup process group.""" + return self._request("POST", "/api/stop", timeout=15.0) + + def load_map(self, db_path: str) -> Dict[str, Any]: + """POST /api/load_map — VIEW a saved map: stop any running bringup, then + start LOCALIZE-only (mode 3) against db_path. Use this for 'Load & View' + so it actually switches to the chosen map instead of attaching to a + mapping session already in progress.""" + return self._request("POST", "/api/load_map", + json_body={"db_path": db_path}, timeout=25.0) + + def shutdown(self) -> Dict[str, Any]: + """POST /api/shutdown — kill ALL registered web_nav3 processes.""" + return self._request("POST", "/api/shutdown", timeout=15.0) + + # ------------------------------------------------------------------ # + # places / navigation + # ------------------------------------------------------------------ # + def list_places(self, map_name: Optional[str] = None) -> List[Dict[str, Any]]: + """GET /api/places. Per-MAP when map_name given (each map keeps its own + places); else the legacy per-robot store. Returns a list (empty on error).""" + params: Dict[str, Any] = {"robot": self.robot} + if map_name: + params["map"] = map_name + res = self._request("GET", "/api/places", params=params) + if not res["ok"]: + logger.debug("list_places failed: %s", res["error"]) + return [] + data = res.get("data") + return data if isinstance(data, list) else [] + + def goto(self, name: str) -> Dict[str, Any]: + """POST /api/places/goto?name=... — THE primary nav command. + + Navigate to a saved place by name on the currently-running bringup. + """ + # Backend runs `timeout 5 ros2 topic pub --once` inside subprocess.run( + # timeout=12); on a Jetson the rclpy cold-start routinely exceeds the + # default 3s read-timeout, so override to match the backend's cap. + res = self._request("POST", "/api/places/goto", params={"name": name}, timeout=12.0) + if res["ok"]: + return {"ok": True, "error": None, "name": name, "data": res.get("data")} + return {"ok": False, "error": res["error"], "name": name} + + def cancel(self) -> Dict[str, Any]: + """Report that server-side goal-cancel is NOT available. + + web_nav3 has no explicit goal-cancel HTTP endpoint, so a true + per-goal cancel must be performed client-side (browser) over rosbridge + by sending a CancelGoal to the /navigate_to_pose action server. That + keeps Nav2/SLAM/bridges alive — only the in-flight goal aborts. + + Server-side there is nothing safe to do here (we must NOT call stop(), + which SIGINTs the whole bringup process group). Previously this returned + ok:True, which made the dashboard's rosbridge-down fallback toast + "cancel sent" while the robot kept driving. Return ok:False with an + explicit reason so callers never mistake this for a real cancel — the + only server-side way to halt motion is stop() (tears down bringup). + """ + return { + "ok": False, + "error": "cancel must be done client-side via rosbridge", + "note": "no server-side goal-cancel; use stop() to tear down bringup", + } + + def save_here(self, name: str) -> Dict[str, Any]: + """Save the robot's CURRENT pose as a named place. + + POST /api/places/save_here?name=... — web_nav3 reads the current TF + pose (requires bringup running; backend takes ``name`` as a query arg). + + CONFIG COUPLING: backend save_here writes the LEGACY places store + (~/marcus_nav2_test/places.json) regardless of robot, while save_at / + list_places are robot-scoped and only resolve to that legacy file when + web_nav3's robot_config.yaml ``robot_name`` equals this client's robot + (``self.robot``). For all three to share one store, web_nav3's + robot_name MUST match SANAD_ROBOT_NAME (default 'sanad'); if they drift, + 'Save here' and click-to-add places silently target different files. + """ + # Same ros2-pub cold-start as goto(): override the 3s default so a slow + # (but successful) publish isn't reported as a failure. + return self._request( + "POST", + "/api/places/save_here", + params={"robot": self.robot, "name": name}, + timeout=12.0, + ) + + def save_at(self, name: str, x: float, y: float, yaw: float, + map_name: Optional[str] = None) -> Dict[str, Any]: + """Save a named place at an explicit (x, y, yaw) map pose. + + POST /api/places/save_at — writes straight to places.json, so it works + even with no bringup running (used by click-to-add-place on the map). + Per-MAP when map_name given. Re-saving an existing name MOVES the place. + """ + params: Dict[str, Any] = {"robot": self.robot} + if map_name: + params["map"] = map_name + return self._request( + "POST", + "/api/places/save_at", + params=params, + json_body={ + "name": name, + "x": float(x), + "y": float(y), + "yaw": float(yaw), + }, + ) + + def delete_place(self, name: str, map_name: Optional[str] = None) -> Dict[str, Any]: + """DELETE /api/places?name=... — remove a saved place (per-map).""" + params: Dict[str, Any] = {"robot": self.robot, "name": name} + if map_name: + params["map"] = map_name + return self._request("DELETE", "/api/places", params=params) + + def rename_place(self, old: str, new: str, + map_name: Optional[str] = None) -> Dict[str, Any]: + """POST /api/places/rename — rename a saved place (per-map).""" + params: Dict[str, Any] = {"robot": self.robot} + if map_name: + params["map"] = map_name + return self._request( + "POST", "/api/places/rename", params=params, + json_body={"old": old, "new": new}, + ) + + # ------------------------------------------------------------------ # + # map editor — per-map occupancy edit overlay + # ------------------------------------------------------------------ # + def get_map_edits(self, map_name: str) -> Dict[str, Any]: + """GET /api/map_edits?map=... — the saved edit overlay (erased points + + painted walls) for a map. Returns {ok, edits:[[wx,wy,v],...]}.""" + res = self._request("GET", "/api/map_edits", + params={"robot": self.robot, "map": map_name}) + if not res["ok"]: + return {"ok": False, "error": res["error"], "edits": []} + data = res.get("data") or {} + return {"ok": True, "edits": data.get("edits", []) if isinstance(data, dict) else []} + + def save_map_edits(self, map_name: str, edits: list) -> Dict[str, Any]: + """POST /api/map_edits?map=... — persist the edit overlay for a map. + edits = list of [world_x, world_y, value] (0=free/erase, 100=wall).""" + return self._request("POST", "/api/map_edits", + params={"robot": self.robot, "map": map_name}, + json_body={"edits": edits}) + + # ------------------------------------------------------------------ # + # maps / missions + # ------------------------------------------------------------------ # + def list_maps(self) -> List[Dict[str, Any]]: + """GET /api/maps. Returns a list (empty on any error).""" + res = self._request("GET", "/api/maps", params={"robot": self.robot}) + if not res["ok"]: + logger.debug("list_maps failed: %s", res["error"]) + return [] + data = res.get("data") + return data if isinstance(data, list) else [] + + def list_missions(self) -> List[Dict[str, Any]]: + """GET /api/missions. Returns a list (empty on any error).""" + res = self._request("GET", "/api/missions", params={"robot": self.robot}) + if not res["ok"]: + logger.debug("list_missions failed: %s", res["error"]) + return [] + data = res.get("data") + return data if isinstance(data, list) else [] + + def run_mission(self, mission_id: Any) -> Dict[str, Any]: + """POST /api/missions/run — start a saved mission by id.""" + return self._request( + "POST", + "/api/missions/run", + params={"id": mission_id}, + json_body={"id": mission_id, "robot": self.robot}, + timeout=15.0, + ) diff --git a/scripts/sanad_script.txt b/scripts/sanad_script.txt index 3a2d184..a6b38e4 100644 --- a/scripts/sanad_script.txt +++ b/scripts/sanad_script.txt @@ -1,4 +1,4 @@ -أنت "بوسنده" — روبوت إماراتي ذكي تابع لروبوت شركة لوتاه تيك Lootah Tech. +أنت "سند iron man" — روبوت إماراتي ذكي تابع لروبوت شركة لوتاه تيك Lootah Tech. [أولاً: الروح والمعرفة] diff --git a/shell_scripts/sanad.service b/shell_scripts/sanad.service index b5c561a..f190569 100644 --- a/shell_scripts/sanad.service +++ b/shell_scripts/sanad.service @@ -1,7 +1,7 @@ -# systemd user-level unit for Sanad. Install with: +# systemd user-level unit for SanadV3. Install with: # # mkdir -p ~/.config/systemd/user -# cp ~/Sanad/shell_scripts/sanad.service ~/.config/systemd/user/sanad.service +# cp ~/Sanadv3/shell_scripts/sanad.service ~/.config/systemd/user/sanad.service # systemctl --user daemon-reload # systemctl --user enable --now sanad.service # sudo loginctl enable-linger unitree # run at boot even when not logged in @@ -13,14 +13,15 @@ # systemctl --user restart sanad.service [Unit] -Description=Sanad robot assistant (FastAPI dashboard + voice/motion subsystems) +Description=SanadV3 robot assistant (FastAPI dashboard + voice/motion subsystems) After=network-online.target Wants=network-online.target [Service] Type=exec -WorkingDirectory=%h/Sanad -ExecStart=/usr/bin/env bash %h/Sanad/shell_scripts/start_sanad.sh +WorkingDirectory=%h/Sanadv3 +ExecStart=/usr/bin/env bash %h/Sanadv3/shell_scripts/start_sanad.sh +Environment=PORT=8001 Restart=on-failure RestartSec=5 TimeoutStopSec=15 diff --git a/shell_scripts/sanadv3.service b/shell_scripts/sanadv3.service new file mode 100644 index 0000000..414d676 --- /dev/null +++ b/shell_scripts/sanadv3.service @@ -0,0 +1,33 @@ +[Unit] +Description=SanadV3 full stack (dashboard :8001 + web_nav3 nav :8765 + rosbridge :9090) +After=network-online.target sound.target +Wants=network-online.target +StartLimitIntervalSec=0 + +[Service] +Type=simple +User=unitree +Group=unitree +WorkingDirectory=/home/unitree/Sanadv3 + +# Runtime env (systemd does NOT source ~/.bashrc, so set what the stack needs). +Environment=USER=unitree +Environment=HOME=/home/unitree +Environment=PYTHONUNBUFFERED=1 +Environment=XDG_RUNTIME_DIR=/run/user/1000 +Environment=PULSE_SERVER=unix:/run/user/1000/pulse/native +# Pin the voice child to Sanadv3 (replaces the old ~/.bashrc SANAD_LIVE_SCRIPT). +Environment=SANAD_LIVE_SCRIPT=/home/unitree/Sanadv3/voice/sanad_voice.py + +# start_all.sh self-activates conda (gemini_sdk for dashboard, g1_lidar for +# web_nav3), supervises both, and has a SIGTERM cleanup trap. +ExecStart=/bin/bash /home/unitree/Sanadv3/shell_scripts/start_all.sh + +# Let start_all.sh's trap tear its children down on stop, then SIGKILL stragglers. +KillMode=mixed +TimeoutStopSec=30 +Restart=always +RestartSec=5s + +[Install] +WantedBy=multi-user.target diff --git a/shell_scripts/start_all.sh b/shell_scripts/start_all.sh new file mode 100644 index 0000000..4453448 --- /dev/null +++ b/shell_scripts/start_all.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# +# start_all.sh — ONE command to launch the entire Sanad humanoid stack: +# +# 1. web_nav3 nav stack → backend :8765 + rosbridge :9090 + map_relay +# + internal rosbridge watchdog +# (runs in its OWN g1_lidar conda env + CycloneDDS) +# +# 2. SanadV3 dashboard → Gemini voice + LED face + Nav tabs, :8001 +# (runs in its OWN gemini_sdk conda env + Unitree SDK on eth0) +# +# The two stacks use incompatible Python envs + DDS configs, so they CANNOT +# share a process. This launcher starts each detached, supervises the pair +# (restarts one only if it truly dies), and Ctrl+C here stops EVERYTHING. +# +# Liveness is checked by PROCESS PATTERN (pgrep), NOT by a captured PID: +# `setsid ... &` returns the ephemeral setsid-wrapper pid which exits +# instantly, so a pid check would false-fire and restart-storm (which then +# makes each failed relaunch's cleanup trap nuke the working rosbridge). +# +# Usage: bash ~/Sanadv3/shell_scripts/start_all.sh +# Env knobs (optional): WEBNAV3_HOME, SANAD_HOME, PORT, LOG_DIR, NO_WEBNAV3=1 + +set -u + +WEBNAV3_HOME="${WEBNAV3_HOME:-$HOME/web_nav3}" +SANAD_HOME="${SANAD_HOME:-$HOME/Sanadv3}" +SANAD_PORT="${PORT:-8001}" +LOG_DIR="${LOG_DIR:-$HOME/sanad_logs}" +NO_WEBNAV3="${NO_WEBNAV3:-0}" +mkdir -p "$LOG_DIR" + +echo "════════════════════════════════════════════════" +echo " Sanad humanoid — full stack launcher" +echo " web_nav3 : $WEBNAV3_HOME $([ "$NO_WEBNAV3" = 1 ] && echo '(skipped)')" +echo " SanadV3 : $SANAD_HOME (port $SANAD_PORT)" +echo " logs : $LOG_DIR" +echo "════════════════════════════════════════════════" + +# liveness by process pattern (robust — no pid tracking) +# NOTE: start_sanad.sh `cd $SANAD_HOME && exec python3 main.py --port N`, so the +# process cmdline is "python3 main.py --port " (no Sanadv3/ in argv). Match +# on the PORT — also keeps us from ever touching the live Sanad on :8000. +SANAD_PAT="main.py --port $SANAD_PORT" +webnav3_up() { pgrep -f "web/backend.py" >/dev/null 2>&1; } +# Count a SanadV3 as "up" if EITHER main.py is bound OR start_sanad.sh is still +# mid-boot (conda activate + up-to-20s DDS-iface wait + heavy lazy imports come +# BEFORE `exec python3 main.py`, so "main.py --port" is absent from argv during +# that whole window). Matching the booting shell too prevents the supervisor +# from pkill-ing a process that is simply still cold-booting (restart storm). +sanad_up() { pgrep -f "$SANAD_PAT" >/dev/null 2>&1 || pgrep -f "shell_scripts/start_sanad.sh" >/dev/null 2>&1; } + +start_webnav3() { + setsid bash "$WEBNAV3_HOME/scripts/start_web.sh" \ + > "$LOG_DIR/web_nav3.log" 2>&1 < /dev/null & + echo " web_nav3 launching → $LOG_DIR/web_nav3.log" +} +start_sanad() { + SANAD_HOME="$SANAD_HOME" PORT="$SANAD_PORT" \ + setsid bash "$SANAD_HOME/shell_scripts/start_sanad.sh" \ + > "$LOG_DIR/sanadv3.log" 2>&1 < /dev/null & + echo " SanadV3 launching → $LOG_DIR/sanadv3.log" +} + +kill_webnav3() { + pkill -9 -f "scripts/start_web.sh" 2>/dev/null || true + pkill -9 -f "web/backend.py|rosbridge_websocket|start_rosbridge.sh|start_map_relay.sh|map_relay.py" 2>/dev/null || true +} + +# ---- teardown on Ctrl+C / TERM ---- +# Installed BEFORE pre-flight/launch so an interrupt during the "clearing +# stale instances" sleep or right after a setsid child is spawned still +# triggers a full teardown (otherwise those detached children would be +# orphaned with no supervisor). +_cleaning=0 +cleanup() { + [ "$_cleaning" = 1 ] && return; _cleaning=1 + echo + echo "── stopping everything ──" + # 1. ask the backend to stop the BRINGUP cleanly (it owns the pgid, so a + # killpg takes down rtabmap + all Nav2 nodes in one shot). + curl -s -m 8 -X POST "http://127.0.0.1:8765/api/shutdown" >/dev/null 2>&1 || true + # 2. graceful TERM: SanadV3 (clean loco stop) + start_web.sh (own trap). + pkill -TERM -f "$SANAD_PAT" 2>/dev/null || true + pkill -TERM -f "scripts/start_web.sh" 2>/dev/null || true + sleep 3 + # 3. force-kill the web_nav3 stack + SanadV3. + kill_webnav3 + pkill -9 -f "$SANAD_PAT" 2>/dev/null || true + pkill -9 -f "shell_scripts/start_sanad.sh" 2>/dev/null || true + # 4. belt-and-suspenders — any orphaned bringup tree (if the backend was + # already gone in step 1, killpg never ran). + pkill -9 -f "scripts/bringup.sh|run_on_jetson.sh|g1_nav2_slam" 2>/dev/null || true + pkill -9 -f "rtabmap_slam/rtabmap|livox_pcd_bridge|dog_odom_to_tf|pointcloud_to_laserscan" 2>/dev/null || true + echo " done." + exit 0 +} +trap cleanup INT TERM + +# ---- pre-flight: clear any stale instances so we start clean ---- +echo "── clearing any stale instances … ──" +[ "$NO_WEBNAV3" != 1 ] && kill_webnav3 +pkill -9 -f "shell_scripts/start_sanad.sh" 2>/dev/null || true +pkill -9 -f "$SANAD_PAT" 2>/dev/null || true +sleep 2 + +# ---- launch ---- +if [ "$NO_WEBNAV3" != 1 ]; then echo "[1/2] starting web_nav3 nav stack …"; start_webnav3; fi +echo "[2/2] starting SanadV3 dashboard …"; start_sanad + +# ---- wait for ports, then report ---- +echo +echo "── waiting for services to bind (up to ~80s) … ──" +for _ in $(seq 1 40); do + up8001=$(ss -tnln 2>/dev/null | grep -c ":$SANAD_PORT ") + up8765=$(ss -tnln 2>/dev/null | grep -c ':8765 ') + up9090=$(ss -tnln 2>/dev/null | grep -c ':9090 ') + if [ "$NO_WEBNAV3" = 1 ]; then + [ "$up8001" -ge 1 ] && break + else + [ "$up8001" -ge 1 ] && [ "$up8765" -ge 1 ] && [ "$up9090" -ge 1 ] && break + fi + sleep 2 +done + +IP=$(hostname -I | awk '{print $1}') +st() { [ "$(ss -tnln 2>/dev/null | grep -c "$1")" -ge 1 ] && echo UP || echo DOWN; } +echo +echo "════════════════════════════════════════════════" +echo " STATUS" +[ "$NO_WEBNAV3" != 1 ] && echo " web_nav3 backend :8765 $(st ':8765 ')" +[ "$NO_WEBNAV3" != 1 ] && echo " rosbridge :9090 $(st ':9090 ')" +echo " SanadV3 dashboard :$SANAD_PORT $(st ":$SANAD_PORT ")" +echo +echo " OPEN: http://$IP:$SANAD_PORT (full dashboard)" +[ "$NO_WEBNAV3" != 1 ] && echo " http://$IP:8765 (web_nav3 nav only)" +echo +echo " logs: tail -f $LOG_DIR/sanadv3.log" +[ "$NO_WEBNAV3" != 1 ] && echo " tail -f $LOG_DIR/web_nav3.log" +echo " >>> Ctrl+C here stops EVERYTHING <<<" +echo "════════════════════════════════════════════════" + +# ---- supervise: restart a child ONLY if it truly died (pgrep), with grace ---- +while true; do + sleep 10 + if [ "$NO_WEBNAV3" != 1 ] && ! webnav3_up; then + echo "[supervisor] $(date +%T) web_nav3 down — restarting" + kill_webnav3 # clear any half-dead remnants first + sleep 1 + start_webnav3 + sleep 15 # grace: let it boot before the next check + fi + if ! sanad_up; then + echo "[supervisor] $(date +%T) SanadV3 down — restarting" + pkill -9 -f "shell_scripts/start_sanad.sh" 2>/dev/null || true + sleep 1 + start_sanad + # grace MUST exceed worst-case cold boot: up to 20s DDS-iface wait + + # conda activate + heavy lazy imports (torch/transformers/gemini) + + # arm.init before uvicorn binds. 15s was shorter than that and caused a + # restart storm (kill a still-booting instance, relaunch, repeat). + sleep 45 + fi +done diff --git a/shell_scripts/start_sanad.sh b/shell_scripts/start_sanad.sh index 7997815..b342ec5 100755 --- a/shell_scripts/start_sanad.sh +++ b/shell_scripts/start_sanad.sh @@ -5,24 +5,24 @@ # (sanad.service) for boot-time auto-start. # # Override knobs (env vars; all optional): -# SANAD_HOME project root (default ~/Sanad) +# SANAD_HOME project root (default ~/Sanadv3) # SANAD_CONDA_ENV conda env name (default gemini_sdk) # SANAD_CONDA_BASE conda install dir (default $HOME/miniconda3) # SANAD_DDS_INTERFACE DDS network iface (default eth0) # SANAD_VOICE_BRAIN gemini | local | model (default gemini) # SANAD_AUDIO_PROFILE builtin | anker | hollyland_builtin (default builtin) -# PORT dashboard port (default 8000) +# PORT dashboard port (default 8001) set -u -SANAD_HOME="${SANAD_HOME:-$HOME/Sanad}" +SANAD_HOME="${SANAD_HOME:-$HOME/Sanadv3}" SANAD_CONDA_ENV="${SANAD_CONDA_ENV:-gemini_sdk}" SANAD_CONDA_BASE="${SANAD_CONDA_BASE:-$HOME/miniconda3}" export SANAD_DDS_INTERFACE="${SANAD_DDS_INTERFACE:-eth0}" export SANAD_VOICE_BRAIN="${SANAD_VOICE_BRAIN:-gemini}" export SANAD_AUDIO_PROFILE="${SANAD_AUDIO_PROFILE:-builtin}" -export PORT="${PORT:-8000}" +export PORT="${PORT:-8001}" # Mandatory environment fixes for Jetson + conda + Unitree SDK export LD_PRELOAD="${LD_PRELOAD:-/usr/lib/aarch64-linux-gnu/libgomp.so.1}" @@ -66,12 +66,17 @@ conda activate "$SANAD_CONDA_ENV" || { } # Wait for the DDS interface to come up — robot may still be booting +_dds_up=0 for i in $(seq 1 20); do if ip link show "$SANAD_DDS_INTERFACE" 2>/dev/null | grep -q "state UP"; then + _dds_up=1 break fi sleep 1 done +if [ "$_dds_up" != 1 ]; then + echo "[start_sanad] WARNING: $SANAD_DDS_INTERFACE not UP after 20s — launching anyway; hardware (Unitree SDK/arm/loco) may be unavailable" >&2 +fi echo "[start_sanad] $(date) — launching main.py" echo "[start_sanad] SANAD_HOME=$SANAD_HOME" diff --git a/vision/camera.py b/vision/camera.py index 7f150f8..5564a82 100644 --- a/vision/camera.py +++ b/vision/camera.py @@ -23,6 +23,11 @@ from __future__ import annotations import base64 import os +import select +import shutil +import socket +import subprocess +import tempfile import threading import time from typing import Optional @@ -39,6 +44,12 @@ log = get_logger("camera") # and accept the first that yields a real 3-channel BGR frame. _USB_SCAN_RANGE = 10 +# R1 head video streams (RTP/H.264, one UDP port each, published by the +# "Stereo patch PC1" service). The head has three fisheye eyes pointing +# different ways plus a depth stream — the dashboard "Camera Source" +# selector maps these labels to ports. +_R1_SOURCE_LABELS = {5000: "depth", 5001: "front", 5002: "left", 5003: "right"} + class CameraDaemon: """RealSense → USB fallback camera capture with in-memory frame cache.""" @@ -62,6 +73,14 @@ class CameraDaemon: self._fps = int(fps) self._q = max(10, min(95, int(jpeg_quality))) self._reconfig_pending = False + # R1 head video source (UDP port / eye) — swappable from the dashboard. + self._r1_port = int(os.environ.get("SANAD_R1_VIDEO_PORT", "5001")) + # Fisheye de-warp — hot toggle (applied per-frame, no pipeline rebuild). + self._dewarp = os.environ.get("SANAD_R1_DEWARP", "0") == "1" + # Cached undistortion remap tables, keyed by frame (w, h). + self._dewarp_wh: Optional[tuple] = None + self._dewarp_map1 = None + self._dewarp_map2 = None # Resilience knobs (Marcus-style) self._stale_s = float(stale_threshold_s) @@ -137,6 +156,7 @@ class CameraDaemon: """Stop the capture thread and release the hardware.""" if not self.is_running(): self._backend = None + self._clear_cache() return self._stop.set() t = self._thread @@ -144,30 +164,52 @@ class CameraDaemon: t.join(timeout=2.0) self._thread = None self._backend = None + # Drop the last captured frame so snapshot_jpeg()/get_frame_b64() + # return None once vision is OFF — otherwise the /frame.jpg preview + # and the enroll path keep serving a frozen image of whoever was + # last in front of the camera. + self._clear_cache() log.info("Camera stopped") + def _clear_cache(self) -> None: + """Drop the cached frame views so nothing stale is served.""" + with self._lock: + self._latest_jpeg = None + self._latest_b64 = None + self._latest_ts = 0.0 + def reconfigure(self, width: Optional[int] = None, height: Optional[int] = None, - fps: Optional[int] = None, jpeg_quality: Optional[int] = None) -> dict: + fps: Optional[int] = None, jpeg_quality: Optional[int] = None, + r1_port: Optional[int] = None, dewarp: Optional[bool] = None) -> dict: """Hot-swap the capture profile without a full stop/start. - Sets a pending flag — the capture loop notices it, tears the - pipeline down, and rebuilds at the new resolution (~0.5 s gap). - If the daemon isn't running the new values just take effect on - the next `start()`. Returns the resulting active profile. + Resolution / FPS / quality / the R1 source port all require the + pipeline to be rebuilt (~0.5 s gap): we set a pending flag the + capture loop notices, tears down, and rebuilds at the new profile. + `dewarp` is applied per-frame, so toggling it takes effect instantly + with no rebuild. If the daemon isn't running the new values just + take effect on the next `start()`. Returns the resulting profile. """ + needs_rebuild = False with self._reconfig_lock: if width is not None: - self._w = int(width) + self._w = int(width); needs_rebuild = True if height is not None: - self._h = int(height) + self._h = int(height); needs_rebuild = True if fps is not None: - self._fps = int(fps) + self._fps = int(fps); needs_rebuild = True if jpeg_quality is not None: - self._q = max(10, min(95, int(jpeg_quality))) - if self.is_running(): + self._q = max(10, min(95, int(jpeg_quality))); needs_rebuild = True + if r1_port is not None: + self._r1_port = int(r1_port); needs_rebuild = True + if dewarp is not None: + self._dewarp = bool(dewarp) # hot — no rebuild + if needs_rebuild and self.is_running(): self._reconfig_pending = True profile = {"width": self._w, "height": self._h, - "fps": self._fps, "jpeg_quality": self._q} + "fps": self._fps, "jpeg_quality": self._q, + "r1_port": self._r1_port, "dewarp": self._dewarp, + "source": _R1_SOURCE_LABELS.get(self._r1_port, str(self._r1_port))} log.info("Camera reconfigure → %s", profile) return profile @@ -191,7 +233,10 @@ class CameraDaemon: Used by face enrollment so the captured frame is guaranteed to be the *current* scene, not a stale buffer from before the user got - into position. Falls back to whatever's cached on timeout. + into position. On timeout, only falls back to the cached frame if + it is still within the stale threshold — otherwise returns None so + the enroll route raises 409 rather than capturing an old scene + (e.g. while the daemon is stuck reconnecting). """ deadline = time.time() + timeout_s while time.time() < deadline: @@ -201,8 +246,15 @@ class CameraDaemon: and (time.time() - self._latest_ts) <= max_age_s): return self._latest_jpeg time.sleep(0.03) + # Timed out waiting for a fresh frame. Hand back the cached frame + # only if it isn't dangerously stale; never enrol an arbitrarily + # old scene. with self._lock: - return self._latest_jpeg + if (self._latest_jpeg is not None + and self._latest_ts > 0 + and (time.time() - self._latest_ts) <= self._stale_s): + return self._latest_jpeg + return None def latest_age_s(self) -> float: """Seconds since last successful frame; +inf if none.""" @@ -214,34 +266,197 @@ class CameraDaemon: def status(self) -> dict: with self._reconfig_lock: w, h, f, q = self._w, self._h, self._fps, self._q + r1_port, dewarp = self._r1_port, self._dewarp # latest_age_s() is +inf until the first frame lands. inf is NOT # JSON-serialisable by Starlette's JSONResponse (allow_nan=False) — # leaving it as inf would 500 the /api/recognition/* routes. Map # "running but no frame yet" and "not running" both to None. age = self.latest_age_s() - age_s = round(age, 2) if (self.is_running() and age != float("inf")) else None + running = self.is_running() + age_s = round(age, 2) if (running and age != float("inf")) else None + # Snapshot the report counters under _lock for a consistent view — + # the capture/reconnect thread mutates these (see _reconnect_loop). + # Read latest_age_s()/is_running() above (they self-lock) so we + # don't re-enter this non-reentrant lock. + with self._lock: + backend = self._backend + frame_seq = self._frame_seq + error = self._error + reconnect_count = self._reconnect_count return { - "running": self.is_running(), - "backend": self._backend, + "running": running, + "backend": backend, "width": w, "height": h, "fps": f, "jpeg_quality": q, - "frame_seq": self._frame_seq, + "r1_port": r1_port, + "dewarp": dewarp, + "source": _R1_SOURCE_LABELS.get(r1_port, str(r1_port)), + "frame_seq": frame_seq, "age_s": age_s, - "error": self._error, - "reconnect_count": self._reconnect_count, + "error": error, + "reconnect_count": reconnect_count, } # ── helpers ───────────────────────────────────────────── def _probe_any(self) -> Optional[dict]: - """Try RealSense first, then USB. Returns backend dict or None.""" - b = self._probe_realsense() + """R1 head stream first, then RealSense, then USB. Backend dict or None.""" + b = self._probe_r1_stereo() + if b is None: + b = self._probe_realsense() if b is None: b = self._probe_usb() return b + def _rtp_relay_port(self, src_port: int) -> int: + """Private loopback port ffmpeg reads instead of the raw source port.""" + return 15000 + (src_port - 5000) + + def _start_rtp_relay(self, src_port: int, dst_port: int): + """Forward RTP from `src_port` to 127.0.0.1:`dst_port`, binding the source + with SO_REUSEADDR so it coexists with the R1 videohub (which holds + 5002/5003). ffmpeg then reads the private loopback port, so its RTP+RTCP + pair never collides with the videohub's ports — the reason decoding + straight off 5001 fails ("bind failed: address already in use" for the + RTCP port 5002). Returns a stop Event, or None if the source bind fails.""" + stop = threading.Event() + + def run(): + try: + rx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + rx.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + rx.bind(("0.0.0.0", src_port)) + rx.settimeout(0.5) + except Exception as exc: + log.warning("RTP relay: bind :%d failed: %s", src_port, exc) + stop.set() + return + tx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + dst = ("127.0.0.1", dst_port) + while not stop.is_set(): + try: + data, _ = rx.recvfrom(65535) + tx.sendto(data, dst) + except socket.timeout: + continue + except Exception: + break + for s in (rx, tx): + try: + s.close() + except Exception: + pass + + t = threading.Thread(target=run, daemon=True, name="rtp-relay-%d" % src_port) + t.start() + return stop + + def _probe_r1_stereo(self) -> Optional[dict]: + """R1 EDU head camera — the RTP/H.264 stereo stream published by the robot's + "stereo_patch_pc1" video service. We (optionally) START that service via the + robot_state DDS call (phone-free; SANAD_R1_STREAM_AUTOSTART=0 to disable), + then decode with a *system* ffmpeg subprocess (the container's cv2-bundled + ffmpeg lacks RTP/SDP support). Because the R1 videohub owns UDP 5002/5003, + ffmpeg can't grab the RTCP port for a 5001 decode, so we run a tiny + SO_REUSEADDR RTP relay (source → private loopback port) and point ffmpeg + there. Returns None (fails fast) when no stream arrives, so RealSense/USB + still get a turn. The eye (port) and resolution/FPS follow the live daemon + profile (dashboard Camera Source + Resolution/FPS controls).""" + if os.environ.get("SANAD_R1_VIDEO", "1") == "0": + return None + ffmpeg = shutil.which("ffmpeg") + if not ffmpeg: + log.info("R1 stereo camera: system ffmpeg not installed") + return None + with self._reconfig_lock: + port = self._r1_port + w, h, fps = self._w, self._h, self._fps + self._reconfig_pending = False # this build satisfies any pending reconfigure + # Phone-free stream start: ask the robot_state service to enable the stereo + # patch (idempotent). Give PC1 a moment to spin up before we read. + if os.environ.get("SANAD_R1_STREAM_AUTOSTART", "1") != "0": + try: + from Project.Sanad.vision.stream_service import get_stream_service + if get_stream_service().set_stereo(True): + time.sleep(0.5) # let the DDS call land; the 6 s read below + # absorbs the rest of PC1's spin-up latency + except Exception as exc: + log.info("R1 stereo auto-start skipped: %s", exc) + # Relay the source port to a private loopback port for ffmpeg. + relay_port = self._rtp_relay_port(port) + relay_stop = self._start_rtp_relay(port, relay_port) + if relay_stop is None: + return None + sdp = ( + "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=R1\r\nc=IN IP4 127.0.0.1\r\n" + "t=0 0\r\nm=video %d RTP/AVP 96\r\n" + "a=rtpmap:96 H264/90000\r\na=fmtp:96 packetization-mode=1\r\n" % relay_port + ) + sdp_path = os.path.join(tempfile.gettempdir(), "r1_front_%d.sdp" % relay_port) + try: + with open(sdp_path, "w") as fh: + fh.write(sdp) + except Exception: + relay_stop.set() + return None + cmd = [ + ffmpeg, "-hide_banner", "-loglevel", "error", + "-protocol_whitelist", "file,rtp,udp", + "-fflags", "nobuffer", "-flags", "low_delay", + "-analyzeduration", "3M", "-probesize", "3M", + "-i", sdp_path, + "-an", "-sn", + "-vf", "scale=%d:%d" % (w, h), "-r", str(fps), + "-f", "rawvideo", "-pix_fmt", "bgr24", "-", + ] + try: + proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + bufsize=0, + ) + except Exception as exc: + log.info("R1 stereo camera: ffmpeg spawn failed: %s", exc) + relay_stop.set() + return None + backend = {"name": "r1_stereo", "proc": proc, "wh": (w, h), + "frame_wh": (w, h), "is_color": True, "sdp_path": sdp_path, + "port": port, "relay_stop": relay_stop} + # Confirm the stream is live: try to read one frame within ~6 s (ffmpeg + # needs to bind the port + wait for the next H.264 keyframe). If nothing + # arrives, the service is off — tear down and fall through. + frame = self._read_r1_frame(backend, timeout_s=6.0) + if frame is None: + self._teardown(backend) + return None + log.info("R1 head camera: %s eye RTP/H.264 udp:%d via ffmpeg (%dx%d @ %dfps)", + _R1_SOURCE_LABELS.get(port, str(port)), port, w, h, fps) + return backend + + def _read_r1_frame(self, backend: dict, timeout_s: float = 2.0): + """Read one rawvideo BGR frame (w*h*3 bytes) from the ffmpeg subprocess.""" + proc = backend["proc"] + w, h = backend["wh"] + need = w * h * 3 + buf = bytearray() + deadline = time.time() + timeout_s + stdout = proc.stdout + while len(buf) < need: + if proc.poll() is not None: + return None + r, _, _ = select.select([stdout], [], [], 0.5) + if not r: + if time.time() > deadline: + return None + continue + chunk = stdout.read(need - len(buf)) + if not chunk: + return None + buf.extend(chunk) + deadline = time.time() + timeout_s + return np.frombuffer(bytes(buf), dtype=np.uint8).reshape((h, w, 3)) + def _check_usb_version(self) -> None: """Warn if a connected RealSense negotiated USB 2.0 (needs 3.x). @@ -275,6 +490,7 @@ class CameraDaemon: def _probe_realsense(self) -> Optional[dict]: with self._reconfig_lock: w, h, f = self._w, self._h, self._fps + self._reconfig_pending = False # this build satisfies any pending reconfigure try: import pyrealsense2 as rs # type: ignore pipeline = rs.pipeline() @@ -347,6 +563,7 @@ class CameraDaemon: """ with self._reconfig_lock: w, h, f = self._w, self._h, self._fps + self._reconfig_pending = False # this build satisfies any pending reconfigure try: import cv2 # type: ignore except Exception as exc: @@ -409,15 +626,37 @@ class CameraDaemon: def _reconnect_loop(self, initial_backend: dict) -> None: """Outer loop — owns reconnect with exponential backoff. - Inner `_capture_session` runs until the camera goes stale, the - stop flag is set, or a reconfigure is requested. On stall we - sleep + re-probe; on reconfigure we re-probe immediately at the - new resolution. Backoff resets after a successful session. + Invariant: a torn-down backend is NEVER reused. After every capture + session we drop the local `backend` and, at the top of the loop, + (re)probe a fresh live backend at the *current* profile — so a + source/resolution switch or a stall always rebuilds cleanly and + `_capture_session` can never spin on a killed subprocess. On probe + failure `self._backend` is nulled so status() reports "reconnecting" + (not a false-green badge on a dead eye) and the MJPEG preview + detaches rather than freezing. `_reconfig_pending` is cleared inside + the probe (atomically with reading the profile it builds against), + so a rebuilt session doesn't immediately re-rebuild. """ - backend = initial_backend + backend = initial_backend # already probed synchronously in start() backoff = self._reconnect_min_s while not self._stop.is_set(): + # (Re)acquire a live backend at the current profile if we lack one. + if backend is None: + backend = self._probe_any() + if backend is None: + self._backend = None # surface "reconnecting", not a false OK + self._error = "reconnecting" + log.warning("Camera source down — retrying in %.1fs", backoff) + if self._stop.wait(backoff): + break + backoff = min(backoff * 2, self._reconnect_max_s) + continue + self._backend = backend["name"] + self._error = None + backoff = self._reconnect_min_s + log.info("Camera acquired (backend=%s)", self._backend) + reconfigured = False try: reconfigured = self._capture_session(backend) @@ -426,49 +665,20 @@ class CameraDaemon: self._error = str(exc) finally: self._teardown(backend) + backend = None # never reuse a torn-down backend if self._stop.is_set(): break - if reconfigured: - # Fast path — rebuild immediately at the new profile. - with self._reconfig_lock: - self._reconfig_pending = False - new_backend = self._probe_any() - if new_backend is None: - self._error = "reconnecting" - log.warning("Camera reconfigure: re-probe failed — " - "retrying in %.1fs", backoff) - if self._stop.wait(backoff): - break - backoff = min(backoff * 2, self._reconnect_max_s) - continue - self._backend = new_backend["name"] - self._error = None - backend = new_backend - backoff = self._reconnect_min_s - log.info("Camera rebuilt after reconfigure (backend=%s)", - self._backend) - continue - - # Capture session ended unexpectedly (stall / crash). Sleep + re-probe. - self._error = "reconnecting" - log.warning("Camera disconnected — reconnecting in %.1fs", backoff) - if self._stop.wait(backoff): # interruptible sleep - break - backoff = min(backoff * 2, self._reconnect_max_s) - - new_backend = self._probe_any() - if new_backend is None: - self._backend = None - continue # stay in the loop; next iteration retries - self._backend = new_backend["name"] - self._reconnect_count += 1 - self._error = None - log.info("Camera reconnected (backend=%s, attempt #%d)", - self._backend, self._reconnect_count) - backend = new_backend - backoff = self._reconnect_min_s # reset on success + # reconfigure → re-probe immediately (loop top, no backoff); + # stall/crash → count it, back off, then re-probe at the top. + if not reconfigured: + self._reconnect_count += 1 + self._error = "reconnecting" + log.warning("Camera disconnected — reconnecting in %.1fs", backoff) + if self._stop.wait(backoff): # interruptible sleep + break + backoff = min(backoff * 2, self._reconnect_max_s) def _capture_session(self, backend: dict) -> bool: """Inner capture loop — runs until stop, stale-frame timeout, or @@ -506,6 +716,10 @@ class CameraDaemon: time.sleep(0.05) continue + # Fisheye de-warp (hot toggle, read live so it needs no rebuild). + if self._dewarp: + bgr = self._apply_dewarp(bgr, cv2) + try: ok, buf = cv2.imencode(".jpg", bgr, encode_params) except Exception as exc: @@ -541,6 +755,8 @@ class CameraDaemon: # Soft path — single timeouts handled by _capture_session's # stale-detection logic; don't spam the log per frame. return None + elif name == "r1_stereo": + return self._read_r1_frame(backend, timeout_s=3.0) elif name == "usb": cap = backend["cap"] ok, frame = cap.read() @@ -549,6 +765,56 @@ class CameraDaemon: return frame return None + # ── fisheye de-warp ───────────────────────────────────── + + def _get_dewarp_maps(self, w: int, h: int, cv2): + """Precompute + cache undistortion remap tables for a (w, h) frame. + + The R1 head cam is a wide fisheye with no published calibration, so + the camera matrix K and distortion coeffs are *approximated* (all + env-tunable) — this straightens lines approximately, not to metric + accuracy. Maps are cached per resolution; cv2.remap() then applies + them cheaply per frame. + """ + # Gate on resolution alone: _dewarp_wh is set to (w, h) after BOTH a + # successful build and a failed one, so a match means "already + # attempted at this size" — return the cached maps (or the cached + # (None, None) on prior failure) instead of rebuilding every frame. + if self._dewarp_wh == (w, h): + return self._dewarp_map1, self._dewarp_map2 + try: + focal = float(os.environ.get("SANAD_R1_DEWARP_FOCAL", "0.55")) * w + k1 = float(os.environ.get("SANAD_R1_DEWARP_K1", "-0.35")) + k2 = float(os.environ.get("SANAD_R1_DEWARP_K2", "0.14")) + alpha = float(os.environ.get("SANAD_R1_DEWARP_ALPHA", "0.0")) + K = np.array([[focal, 0, w / 2.0], + [0, focal, h / 2.0], + [0, 0, 1.0]], dtype=np.float64) + dist = np.array([k1, k2, 0.0, 0.0, 0.0], dtype=np.float64) + newK, _ = cv2.getOptimalNewCameraMatrix(K, dist, (w, h), alpha, (w, h)) + map1, map2 = cv2.initUndistortRectifyMap( + K, dist, None, newK, (w, h), cv2.CV_16SC2) + self._dewarp_wh = (w, h) + self._dewarp_map1, self._dewarp_map2 = map1, map2 + return map1, map2 + except Exception as exc: + log.warning("de-warp map build failed (%dx%d): %s", w, h, exc) + self._dewarp_wh = (w, h) + self._dewarp_map1 = self._dewarp_map2 = None + return None, None + + def _apply_dewarp(self, bgr: np.ndarray, cv2) -> np.ndarray: + """Remap a BGR frame through the cached undistortion tables.""" + h, w = bgr.shape[:2] + m1, m2 = self._get_dewarp_maps(w, h, cv2) + if m1 is None: + return bgr + try: + return cv2.remap(bgr, m1, m2, interpolation=cv2.INTER_LINEAR, + borderMode=cv2.BORDER_CONSTANT) + except Exception: + return bgr + def _teardown(self, backend: dict) -> None: name = backend.get("name") try: @@ -556,5 +822,21 @@ class CameraDaemon: backend["pipeline"].stop() elif name == "usb": backend["cap"].release() + elif name == "r1_stereo": + relay_stop = backend.get("relay_stop") + if relay_stop is not None: + relay_stop.set() # stop the RTP relay thread + free the port + proc = backend.get("proc") + if proc is not None: + try: + proc.kill() + proc.wait(timeout=2) + except Exception: + pass + if backend.get("sdp_path"): + try: + os.remove(backend["sdp_path"]) + except Exception: + pass except Exception as exc: log.info("Camera teardown: %s", exc) diff --git a/vision/face_gallery.py b/vision/face_gallery.py index 1b637ab..25db847 100644 --- a/vision/face_gallery.py +++ b/vision/face_gallery.py @@ -97,9 +97,14 @@ class FaceGallery: yield int(m.group(1)), child def _samples_in(self, face_dir: Path) -> list[Path]: + # Count only real face_N. samples — a stray non-conforming + # image (e.g. a manually dropped thumbnail.jpg) must not inflate the + # sample count, or delete_photo's "only photo" guard could let the + # last real sample be deleted, leaving the face with zero usable + # samples for the primer. out: list[Path] = [] for p in sorted(face_dir.iterdir()): - if p.is_file() and p.suffix.lower() in ALLOWED_EXTS: + if p.is_file() and SAMPLE_NAME_RE.match(p.name): out.append(p) return out diff --git a/vision/recognition_state.py b/vision/recognition_state.py index ccd4dd3..12b01c7 100644 --- a/vision/recognition_state.py +++ b/vision/recognition_state.py @@ -40,6 +40,10 @@ class RecognitionState: nav_target_place_id: int = 0 # N2 — Gemini-driven locomotion enable gate (default OFF for safety) movement_enabled: bool = False + # Auto-record every conversation turn to data/recordings/ (default ON to + # match historical behavior). Toggled live from the Live Gemini panel; the + # child syncs TurnRecorder.enabled to this without a session restart. + record_enabled: bool = True def read(path: Path) -> RecognitionState: @@ -63,6 +67,7 @@ def read(path: Path) -> RecognitionState: nav_target_zone_id=int(raw.get("nav_target_zone_id", 0)), nav_target_place_id=int(raw.get("nav_target_place_id", 0)), movement_enabled=bool(raw.get("movement_enabled", False)), + record_enabled=bool(raw.get("record_enabled", True)), ) diff --git a/vision/stream_service.py b/vision/stream_service.py new file mode 100644 index 0000000..1d6cd86 --- /dev/null +++ b/vision/stream_service.py @@ -0,0 +1,82 @@ +"""R1 head video-service control via the robot_state DDS service (api_id 1001). + +Phone-free equivalent of the Unitree app's Settings → Service Status toggle for +the stereo camera: `RobotStateClient.ServiceSwitch("stereo_patch_pc1", on)`. +VIDEO ONLY — this never touches motion/loco services. + +Reuses the app's already-initialized DDS ChannelFactory (arm_controller.init() +calls ChannelFactoryInitialize once per process), so the client is created +lazily on first use — by then the arm subsystem has set the factory up. If the +SDK/DDS isn't available it degrades to a no-op (returns False) instead of raising. +""" +from __future__ import annotations + +import threading +from typing import Optional + +from Project.Sanad.core.logger import get_logger + +log = get_logger("stream_service") + +STEREO_SERVICE = "stereo_patch_pc1" + + +class StreamService: + """Thin, thread-safe wrapper around RobotStateClient for the stereo stream.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._client = None + self._unavailable = False + + def _client_or_none(self): + if self._client is not None: + return self._client + if self._unavailable: + return None + try: + # Reuses the process-wide ChannelFactory (already initialized by the + # arm controller). Does NOT re-init it — that's the single-init rule. + from unitree_sdk2py.go2.robot_state.robot_state_client import ( + RobotStateClient, + ) + c = RobotStateClient() + c.Init() + c.SetTimeout(3.0) + self._client = c + log.info("robot_state client ready (stereo video-service control)") + return c + except Exception as exc: + # No SDK, DDS not up yet, or robot link down — mark unavailable so we + # don't retry-spam; a process restart re-attempts. + log.info("robot_state client unavailable (%s) — stream auto-control off", exc) + self._unavailable = True + return None + + def set_stereo(self, on: bool) -> bool: + """Start (on=True) / stop (on=False) the stereo stream. Idempotent — + starting an already-running service is harmless. Returns True on success.""" + with self._lock: + c = self._client_or_none() + if c is None: + return False + try: + code = c.ServiceSwitch(STEREO_SERVICE, bool(on)) + if code == 0: + log.info("stereo stream -> %s", "ON" if on else "OFF") + return True + log.warning("stereo ServiceSwitch(%s) returned code=%s", on, code) + return False + except Exception as exc: + log.warning("stereo ServiceSwitch(%s) failed: %s", on, exc) + return False + + +_instance: Optional[StreamService] = None + + +def get_stream_service() -> StreamService: + global _instance + if _instance is None: + _instance = StreamService() + return _instance diff --git a/vision/zone_gallery.py b/vision/zone_gallery.py index 6f518f6..b021eb5 100644 --- a/vision/zone_gallery.py +++ b/vision/zone_gallery.py @@ -9,12 +9,16 @@ A two-level hierarchy that replaces the flat place gallery: Layout: zones/ zone_{zid}/ - meta.json {name, description, added_at} + meta.json {name, description, added_at, linked_map?} place_{pid}/ - meta.json {name, description, face_ids:[int], added_at} + meta.json {name, description, face_ids:[int], added_at, nav_place?} place_1.jpg ← optional reference photos (0..N) place_2.png +`linked_map` (optional) binds a zone to a nav2 map .db; `nav_place` (optional) +links a place to a nav2 place name in that map so it can be DRIVEN to. Both are +only written when set (absent = None on read), so old metadata stays valid. + `face_ids` reference enrolled faces in the SEPARATE face gallery (data/faces/face_{id}); this module only stores the ids — name/photo resolution is done by the caller (route layer + Gemini primer). @@ -56,6 +60,9 @@ class PlaceEntry: description: str | None = None face_ids: list[int] = field(default_factory=list) sample_paths: list[Path] = field(default_factory=list) + # Name of the nav2 saved place (in the zone's linked map) this vision place + # drives to. None = announce/recognise only, no driving. + nav_place: str | None = None def to_dict(self) -> dict: return { @@ -64,6 +71,7 @@ class PlaceEntry: "name": self.name, "description": self.description, "face_ids": list(self.face_ids), + "nav_place": self.nav_place, "added_at": self.added_at, "photos": [ {"name": p.name, "size_bytes": p.stat().st_size} @@ -80,12 +88,17 @@ class ZoneEntry: dir: Path description: str | None = None places: list[PlaceEntry] = field(default_factory=list) + # nav2 map (.db basename, e.g. "office.db") this zone is bound to. A zone + # with a linked map can be driven in via "Gemini Nav"; its places link to + # that map's nav2 places. + linked_map: str | None = None def to_dict(self) -> dict: return { "id": self.id, "name": self.name, "description": self.description, + "linked_map": self.linked_map, "added_at": self.added_at, "places": [p.to_dict() for p in self.places], } @@ -135,24 +148,27 @@ class ZoneGallery: # ── meta ───────────────────────────────────────────────── - def _zone_meta(self, zone_dir: Path) -> tuple[str | None, str | None, str | None]: + def _zone_meta(self, zone_dir: Path) -> tuple[str | None, str | None, str | None, str | None]: + """Returns (name, description, added_at, linked_map).""" meta_path = zone_dir / "meta.json" if not meta_path.exists(): - return None, None, None + return None, None, None, None try: data = json.loads(meta_path.read_text(encoding="utf-8")) except Exception: - return None, None, None - return (data.get("name") or None), (data.get("description") or None), data.get("added_at") + return None, None, None, None + return ((data.get("name") or None), (data.get("description") or None), + data.get("added_at"), (data.get("linked_map") or None)) - def _place_meta(self, place_dir: Path) -> tuple[str | None, str | None, list[int], str | None]: + def _place_meta(self, place_dir: Path) -> tuple[str | None, str | None, list[int], str | None, str | None]: + """Returns (name, description, face_ids, added_at, nav_place).""" meta_path = place_dir / "meta.json" if not meta_path.exists(): - return None, None, [], None + return None, None, [], None, None try: data = json.loads(meta_path.read_text(encoding="utf-8")) except Exception: - return None, None, [], None + return None, None, [], None, None raw_ids = data.get("face_ids") or [] face_ids: list[int] = [] for x in raw_ids: @@ -160,26 +176,32 @@ class ZoneGallery: face_ids.append(int(x)) except (TypeError, ValueError): continue - return (data.get("name") or None), (data.get("description") or None), face_ids, data.get("added_at") + return ((data.get("name") or None), (data.get("description") or None), + face_ids, data.get("added_at"), (data.get("nav_place") or None)) - def _write_zone_meta(self, zone_dir: Path, name, description, added_at=None) -> None: + def _write_zone_meta(self, zone_dir: Path, name, description, + added_at=None, linked_map=None) -> None: meta: dict = {} if name: meta["name"] = name if description: meta["description"] = description + if linked_map: + meta["linked_map"] = linked_map meta["added_at"] = added_at or datetime.now().isoformat(timespec="seconds") (zone_dir / "meta.json").write_text( json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") def _write_place_meta(self, place_dir: Path, name, description, - face_ids=None, added_at=None) -> None: + face_ids=None, added_at=None, nav_place=None) -> None: meta: dict = {} if name: meta["name"] = name if description: meta["description"] = description meta["face_ids"] = [int(x) for x in (face_ids or [])] + if nav_place: + meta["nav_place"] = nav_place meta["added_at"] = added_at or datetime.now().isoformat(timespec="seconds") (place_dir / "meta.json").write_text( json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") @@ -187,19 +209,20 @@ class ZoneGallery: # ── read ───────────────────────────────────────────────── def _build_place(self, zone_id: int, place_id: int, place_dir: Path) -> PlaceEntry: - name, desc, face_ids, added = self._place_meta(place_dir) + name, desc, face_ids, added, nav_place = self._place_meta(place_dir) return PlaceEntry( id=place_id, zone_id=zone_id, name=name, description=desc, face_ids=face_ids, added_at=added, dir=place_dir, - sample_paths=self._samples_in(place_dir), + sample_paths=self._samples_in(place_dir), nav_place=nav_place, ) def _build_zone(self, zone_id: int, zone_dir: Path) -> ZoneEntry: - name, desc, added = self._zone_meta(zone_dir) + name, desc, added, linked_map = self._zone_meta(zone_dir) places = [self._build_place(zone_id, pid, pdir) for pid, pdir in self._iter_place_dirs(zone_dir)] return ZoneEntry(id=zone_id, name=name, description=desc, - added_at=added, dir=zone_dir, places=places) + added_at=added, dir=zone_dir, places=places, + linked_map=linked_map) def list_zones(self) -> list[ZoneEntry]: with self._lock: @@ -273,8 +296,9 @@ class ZoneGallery: zd = self._zone_dir(zone_id) if not zd.is_dir(): raise FileNotFoundError(f"zone_{zone_id} not found") - _, desc, added = self._zone_meta(zd) - self._write_zone_meta(zd, (name or "").strip() or None, desc, added_at=added) + _, desc, added, linked_map = self._zone_meta(zd) + self._write_zone_meta(zd, (name or "").strip() or None, desc, + added_at=added, linked_map=linked_map) log.info("Renamed zone_%d → %s", zone_id, name or "(unnamed)") def describe_zone(self, zone_id: int, description: str | None) -> None: @@ -282,10 +306,22 @@ class ZoneGallery: zd = self._zone_dir(zone_id) if not zd.is_dir(): raise FileNotFoundError(f"zone_{zone_id} not found") - name, _, added = self._zone_meta(zd) - self._write_zone_meta(zd, name, (description or "").strip() or None, added_at=added) + name, _, added, linked_map = self._zone_meta(zd) + self._write_zone_meta(zd, name, (description or "").strip() or None, + added_at=added, linked_map=linked_map) log.info("Described zone_%d", zone_id) + def set_zone_map(self, zone_id: int, linked_map: str | None) -> None: + """Bind (or unbind, with None/'') the zone to a nav2 map .db basename.""" + with self._lock: + zd = self._zone_dir(zone_id) + if not zd.is_dir(): + raise FileNotFoundError(f"zone_{zone_id} not found") + name, desc, added, _ = self._zone_meta(zd) + self._write_zone_meta(zd, name, desc, added_at=added, + linked_map=(linked_map or "").strip() or None) + log.info("Linked zone_%d → map %s", zone_id, linked_map or "(none)") + def delete_zone(self, zone_id: int) -> None: import shutil with self._lock: @@ -300,7 +336,8 @@ class ZoneGallery: def create_place(self, zone_id: int, name: str | None = None, description: str | None = None, face_ids: list[int] | None = None, - image_bytes_list: list[bytes] | None = None) -> PlaceEntry: + image_bytes_list: list[bytes] | None = None, + nav_place: str | None = None) -> PlaceEntry: with self._lock: zd = self._zone_dir(zone_id) if not zd.is_dir(): @@ -311,10 +348,11 @@ class ZoneGallery: for idx, data in enumerate(image_bytes_list or [], start=1): (pd / f"place_{idx}{self._detect_ext(data)}").write_bytes(data) self._write_place_meta(pd, (name or "").strip() or None, - (description or "").strip() or None, face_ids or []) - log.info("Created zone_%d/place_%d (name=%s, photos=%d, faces=%d)", + (description or "").strip() or None, face_ids or [], + nav_place=(nav_place or "").strip() or None) + log.info("Created zone_%d/place_%d (name=%s, photos=%d, faces=%d, nav=%s)", zone_id, pid, name or "(unnamed)", - len(image_bytes_list or []), len(face_ids or [])) + len(image_bytes_list or []), len(face_ids or []), nav_place or "-") return self._build_place(zone_id, pid, pd) def rename_place(self, zone_id: int, place_id: int, name: str | None) -> None: @@ -322,8 +360,9 @@ class ZoneGallery: pd = self._place_dir(zone_id, place_id) if not pd.is_dir(): raise FileNotFoundError(f"zone_{zone_id}/place_{place_id} not found") - _, desc, fids, added = self._place_meta(pd) - self._write_place_meta(pd, (name or "").strip() or None, desc, fids, added_at=added) + _, desc, fids, added, navp = self._place_meta(pd) + self._write_place_meta(pd, (name or "").strip() or None, desc, fids, + added_at=added, nav_place=navp) log.info("Renamed zone_%d/place_%d → %s", zone_id, place_id, name or "(unnamed)") def describe_place(self, zone_id: int, place_id: int, description: str | None) -> None: @@ -331,8 +370,9 @@ class ZoneGallery: pd = self._place_dir(zone_id, place_id) if not pd.is_dir(): raise FileNotFoundError(f"zone_{zone_id}/place_{place_id} not found") - name, _, fids, added = self._place_meta(pd) - self._write_place_meta(pd, name, (description or "").strip() or None, fids, added_at=added) + name, _, fids, added, navp = self._place_meta(pd) + self._write_place_meta(pd, name, (description or "").strip() or None, fids, + added_at=added, nav_place=navp) log.info("Described zone_%d/place_%d", zone_id, place_id) def set_place_faces(self, zone_id: int, place_id: int, face_ids: list[int]) -> None: @@ -341,16 +381,29 @@ class ZoneGallery: pd = self._place_dir(zone_id, place_id) if not pd.is_dir(): raise FileNotFoundError(f"zone_{zone_id}/place_{place_id} not found") - name, desc, _, added = self._place_meta(pd) + name, desc, _, added, navp = self._place_meta(pd) clean = [] for x in (face_ids or []): try: clean.append(int(x)) except (TypeError, ValueError): continue - self._write_place_meta(pd, name, desc, clean, added_at=added) + self._write_place_meta(pd, name, desc, clean, added_at=added, nav_place=navp) log.info("Set zone_%d/place_%d faces → %s", zone_id, place_id, clean) + def set_place_nav(self, zone_id: int, place_id: int, nav_place: str | None) -> None: + """Link (or unlink, with None/'') a place to a nav2 place name in the + zone's map. This is what makes a vision place drivable.""" + with self._lock: + pd = self._place_dir(zone_id, place_id) + if not pd.is_dir(): + raise FileNotFoundError(f"zone_{zone_id}/place_{place_id} not found") + name, desc, fids, added, _ = self._place_meta(pd) + self._write_place_meta(pd, name, desc, fids, added_at=added, + nav_place=(nav_place or "").strip() or None) + log.info("Linked zone_%d/place_%d → nav place %s", + zone_id, place_id, nav_place or "(none)") + def add_photo(self, zone_id: int, place_id: int, image_bytes: bytes) -> str: with self._lock: pd = self._place_dir(zone_id, place_id) diff --git a/voice/audio_devices.py b/voice/audio_devices.py index 583136b..8de2b61 100644 --- a/voice/audio_devices.py +++ b/voice/audio_devices.py @@ -50,6 +50,18 @@ class AudioProfile: description: str = "" sink_sample_rate: int = 0 # 0 = use device default source_sample_rate: int = 0 + # True → the profile's REAL mic is the R1/G1 firmware UDP-multicast stream + # (voice/audio_io BuiltinMic), so a missing PulseAudio source must not + # disqualify it — detection falls back to a synthetic "udp:" source and + # apply_selection() skips `pactl set-default-source` for it. + udp_mic_fallback: bool = False + # True → the profile's REAL speaker is the R1/G1 firmware chest speaker + # driven over DDS (voice/audio_io BuiltinSpeaker.PlayStream), NOT a + # PulseAudio sink. The Jetson audio card is input-only (no output sink), so + # a missing PulseAudio sink must NOT disqualify the chest — it is always + # usable. Detection falls back to a synthetic "dds:" sink and + # apply_selection() skips `pactl set-default-sink` for it. + dds_speaker_fallback: bool = False # Built-in device profiles. @@ -77,6 +89,8 @@ PROFILES: list[AudioProfile] = [ sink_pattern="platform-sound", source_pattern="alsa_input.platform-sound", description="Jetson / G1 built-in audio chip. (Default)", + udp_mic_fallback=True, # chest mic = R1/G1 firmware UDP stream, not pulse + dds_speaker_fallback=True, # chest speaker = R1/G1 DDS PlayStream, not pulse ), AudioProfile( id="hollyland_builtin", @@ -92,6 +106,16 @@ PROFILES: list[AudioProfile] = [ source_pattern="powerconf,anker", description="Anker PowerConf USB conference unit — mic + speaker on the same device.", ), + AudioProfile( + id="jbl_builtin_mic", + label="JBL speaker + built-in mic", + # The JBL connects over Bluetooth → its PulseAudio sink is a bluez sink + # (name is MAC-based, e.g. bluez_output.XX_XX_…). Match "jbl" or "bluez". + sink_pattern="jbl,bluez", + # The JBL has NO microphone → input stays on the G1 built-in mic. + source_pattern="alsa_input.platform-sound", + description="JBL Bluetooth speaker for output + the G1 built-in microphone for input (the JBL has no mic).", + ), ] # The profile that should be used when no saved state and no auto-detect succeeds. @@ -478,6 +502,10 @@ def ensure_card_input_capable(card_pattern: str) -> bool: return switched_any +# Profiles that already logged the (permanent) UDP-mic fallback notice. +_UDP_FALLBACK_LOGGED: set[str] = set() + + def detect_plugged_profiles() -> list[dict[str, Any]]: """Return all profiles whose sink AND source are currently plugged in. @@ -497,6 +525,17 @@ def detect_plugged_profiles() -> list[dict[str, Any]]: refreshed_sources = False for prof in PROFILES: sink = find_first_match(sinks, prof.sink_pattern) + if not sink and prof.dds_speaker_fallback: + # The profile's real speaker is the R1/G1 chest driven over DDS + # (AudioClient.PlayStream), not a PulseAudio sink. The Jetson audio + # card is input-only (no output sink exists), so a missing pulse + # sink must NOT disqualify the chest — it is always usable. + # Synthesise a "dds:" sink so the profile stays detected and + # selectable; apply_selection() skips set-default-sink for "dds:". + sink = { + "name": "dds:chest-speaker", + "description": "R1/G1 firmware chest speaker (DDS, no PulseAudio sink)", + } if not sink: continue src = find_first_match(sources, prof.source_pattern, exclude_monitors=True) @@ -525,6 +564,23 @@ def detect_plugged_profiles() -> list[dict[str, Any]]: log.info("detect_plugged_profiles: %s source resolved via " "PyAudio fallback (pactl missed it): %s", prof.id, src.get("name", "?")) + if src is None and prof.udp_mic_fallback: + # The profile's real mic is the R1/G1 firmware UDP-multicast stream + # (voice/audio_io BuiltinMic) — no PulseAudio source needed. The + # profile is fully functional, so a synthetic source keeps it + # selectable. apply_selection() skips `set-default-source` for + # "udp:" names (like "pyaudio:"). + src = { + "name": "udp:chest-multicast-mic", + "description": "R1/G1 firmware UDP-multicast mic (no PulseAudio source)", + } + via = "udp-builtin" + # Steady state on chest-mic robots + polled every few seconds — log + # once, not per poll. + if prof.id not in _UDP_FALLBACK_LOGGED: + _UDP_FALLBACK_LOGGED.add(prof.id) + log.info("detect_plugged_profiles: %s using UDP-mic fallback " + "(no matching pulse source)", prof.id) if sink and src: detected.append({ "profile": asdict(prof), @@ -571,6 +627,13 @@ def save_state(state: dict[str, Any]) -> None: # ───────────────────────── current selection ───────────────────────── +# Dedupe the "manual override stale" fall-through log. current_selection() is +# called every ~1.5s by the audio watcher, so logging every cycle spams the log +# whenever a saved device (e.g. an unplugged Anker) stays absent. We log only +# when the stale state changes (and reset when the override becomes valid). +_LAST_STALE_LOG: Any = None + + def current_selection() -> dict[str, Any]: """Resolve the currently active sink/source. @@ -616,7 +679,9 @@ def current_selection() -> dict[str, Any]: source_names = { s["name"] for s in (list_sources() if pactl_available() else []) } + global _LAST_STALE_LOG if saved_sink in sink_names and saved_source in source_names: + _LAST_STALE_LOG = None # override valid again — re-arm the log return { "source_kind": "manual", "profile": None, @@ -625,7 +690,16 @@ def current_selection() -> dict[str, Any]: "sink_description": "", "source_description": "", } - log.info( + # Benign expected state (a saved device is simply unplugged) — and this + # is hit by every status poll, possibly from more than one process, so a + # module cache can't fully suppress it. Log at DEBUG (off the INFO log), + # and at INFO only ONCE when the stale state first changes, so an + # operator still gets a single breadcrumb without the 30s spam. + _stale_key = (saved_sink, saved_sink in sink_names, + saved_source, saved_source in source_names) + _first = _stale_key != _LAST_STALE_LOG + _LAST_STALE_LOG = _stale_key + (log.info if _first else log.debug)( "current_selection: manual override stale (sink=%s present=%s, " "source=%s present=%s) — falling through to auto-detect", saved_sink, saved_sink in sink_names, @@ -656,6 +730,21 @@ def current_selection() -> dict[str, Any]: "source_description": d["source"].get("description", ""), } + # 4b. R1 / DDS fallback: 'builtin' is the DDS chest speaker + UDP mic — the + # R1's real audio path, active even though it never appears as a PulseAudio + # sink. Report it as the current profile so the panel shows "Built-in mic + + # speaker" instead of "(none)" / auto_null when no USB device is plugged. + builtin = PROFILES_BY_ID.get(DEFAULT_PROFILE_ID) + if builtin is not None: + return { + "source_kind": "builtin_dds", + "profile": asdict(builtin), + "sink": get_default_sink() or "", + "source": get_default_source() or "", + "sink_description": "R1 DDS chest speaker", + "source_description": "R1 UDP mic", + } + # 5. pactl defaults (system-wide) sink = get_default_sink() source = get_default_source() @@ -698,7 +787,11 @@ def apply_selection(sink: str, source: str) -> dict[str, Any]: """ errors: list[str] = [] if sink: - if not set_default_sink(sink): + if sink.startswith("dds:"): + log.info("apply_selection: sink is synthetic (%s) — skipping " + "pactl set-default-sink. The chest speaker plays via DDS " + "(AudioClient.PlayStream), not PulseAudio.", sink) + elif not set_default_sink(sink): errors.append(f"set-default-sink failed: {sink}") else: try: @@ -706,11 +799,11 @@ def apply_selection(sink: str, source: str) -> dict[str, Any]: except (FileNotFoundError, subprocess.SubprocessError): pass if source: - if source.startswith("pyaudio:"): - log.info("apply_selection: source is PyAudio-direct (%s) — " - "skipping pactl set-default-source. Live mic path " - "uses PortAudio device match; pactl defaults stay put.", - source) + if source.startswith(("pyaudio:", "udp:")): + log.info("apply_selection: source is synthetic (%s) — skipping " + "pactl set-default-source. PyAudio-direct mics use " + "PortAudio device match; udp: is the firmware multicast " + "mic. pactl defaults stay put.", source) elif not set_default_source(source): errors.append(f"set-default-source failed: {source}") else: diff --git a/voice/audio_io.py b/voice/audio_io.py index 7b0bdc3..c2b0b09 100644 --- a/voice/audio_io.py +++ b/voice/audio_io.py @@ -659,6 +659,150 @@ class AnkerSpeaker(_PyAudioSpeaker): super().__init__(device_pattern="powerconf,anker", label="Anker") +class PulseStreamSpeaker(Speaker): + """Stream PCM to PulseAudio's default sink via a `pacat` subprocess. + + Why not _PyAudioSpeaker: PortAudio's 'pulse' device is unavailable in this + conda env (the ALSA→pulse plugin libasound_module_conf_pulse.so isn't on the + env's plugin path), so PyAudio can't reach PulseAudio at all → silence. The + record-playback path proved `pacat`/`paplay` work, so we reuse that: pacat + inherits PULSE_SERVER/XDG_RUNTIME_DIR from the child and plays to PulseAudio's + DEFAULT sink — which the dashboard's Apply pins to the active profile's sink + (the JBL). Used by the JBL profile (paired with the G1 built-in DDS mic).""" + + HW_RATE = 24_000 # Gemini's native receive rate; PulseAudio resamples to the sink + + def __init__(self, label: str = "Pulse", sink_pattern: str = ""): + self._label = label + self._sink_pattern = sink_pattern + self._sink_name: Optional[str] = None # resolved PA sink, cached + self._proc: Optional["subprocess.Popen"] = None + self._stop_flag = threading.Event() + self._lock = threading.RLock() + self._total_sent = 0.0 + self._closed = False + + def _resolve_sink(self) -> Optional[str]: + """Find the PA sink whose name matches our pattern (e.g. the JBL), so we + can pin pacat to it with --device instead of relying on the (drift-prone) + default sink. Returns None → pacat falls back to the default sink.""" + if not self._sink_pattern: + return None + pats = [p.strip().lower() for p in self._sink_pattern.split(",") if p.strip()] + try: + out = subprocess.run( + ["pactl", "list", "short", "sinks"], + capture_output=True, text=True, timeout=2, + ).stdout + except Exception: + return None + for line in out.splitlines(): + cols = line.split("\t") + name = cols[1] if len(cols) > 1 else "" + if name and any(p in name.lower() for p in pats): + return name + return None + + def _spawn(self) -> None: + if self._proc is not None and self._proc.poll() is None: + return + if self._sink_name is None: + self._sink_name = self._resolve_sink() + cmd = [ + "pacat", "--playback", + "--rate=%d" % self.HW_RATE, "--format=s16le", "--channels=1", + "--latency-msec=120", + "--client-name=sanad_voice", "--stream-name=sanad_voice_jbl", + ] + if self._sink_name: + cmd.append("--device=%s" % self._sink_name) + try: + self._proc = subprocess.Popen( + cmd, stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + except Exception as exc: + log.warning("%sSpeaker: pacat spawn failed: %s", self._label, exc) + self._proc = None + + def begin_stream(self) -> None: + with self._lock: + self._stop_flag.clear() + self._closed = False + self._total_sent = 0.0 + self._spawn() + + def send_chunk(self, pcm: PCMLike, source_rate: int) -> None: + with self._lock: + if self._stop_flag.is_set() or self._closed: + return + arr = _as_int16_array(pcm) + if arr.size < 10: + return + if source_rate != self.HW_RATE: + arr = _resample_int16(arr, source_rate, self.HW_RATE) + if self._proc is None or self._proc.poll() is not None: + self._spawn() + p = self._proc + if p is None or p.stdin is None: + return + try: + p.stdin.write(arr.tobytes()) + p.stdin.flush() + self._total_sent += len(arr) / self.HW_RATE + except (BrokenPipeError, OSError): + pass + + def wait_finish(self) -> None: + # Close stdin so pacat drains its buffer and exits on its own. + with self._lock: + p = self._proc + self._proc = None + if p is None: + return + try: + if p.stdin: + p.stdin.close() + except Exception: + pass + try: + p.wait(timeout=8) + except Exception: + try: + p.kill() + except Exception: + pass + + def stop(self) -> None: + with self._lock: + self._stop_flag.set() + self._closed = True + p = self._proc + self._proc = None + if p is not None: + try: + if p.stdin: + p.stdin.close() + except Exception: + pass + try: + p.terminate() + p.wait(timeout=2) + except Exception: + try: + p.kill() + except Exception: + pass + + @property + def interrupted(self) -> bool: + return self._stop_flag.is_set() + + @property + def total_sent_sec(self) -> float: + return self._total_sent + + # ─── Factory ────────────────────────────────────────────── _PROFILE_ALIASES = { @@ -669,9 +813,11 @@ _PROFILE_ALIASES = { "anker_powerconf": "anker", "hollyland": "hollyland_builtin", "hollyland_builtin": "hollyland_builtin", + "jbl": "jbl_builtin_mic", + "jbl_builtin_mic": "jbl_builtin_mic", } -SUPPORTED_PROFILES = ("builtin", "anker", "hollyland_builtin") +SUPPORTED_PROFILES = ("builtin", "anker", "hollyland_builtin", "jbl_builtin_mic") @dataclass @@ -733,6 +879,12 @@ class AudioIO: "requires audio_client" ) return HollylandMic(), BuiltinSpeaker(audio_client) + if resolved == "jbl_builtin_mic": + # JBL speaker via pacat → PulseAudio default sink (pinned to the JBL + # by the dashboard) + the G1 built-in DDS mic (the JBL has no mic). + # pacat is used because PyAudio's 'pulse' device is unavailable in + # this env. Neither backend needs the AudioClient. + return BuiltinMic(), PulseStreamSpeaker(label="JBL", sink_pattern="jbl,bluez") raise AssertionError(f"unhandled resolved profile: {resolved!r}") @classmethod diff --git a/voice/audio_manager.py b/voice/audio_manager.py index bff89cf..825cdee 100644 --- a/voice/audio_manager.py +++ b/voice/audio_manager.py @@ -1,7 +1,10 @@ """Audio I/O manager — recording and playback via PyAudio. -Handles microphone capture, speaker playback, and speaker-monitor recording. -Thread-safe; one playback at a time via play_lock. +Handles microphone capture and speaker playback. Thread-safe; one +playback at a time via play_lock. + +(Speaker-monitor / `.monitor`-source capture lives in voice/typed_replay.py, +not here — see its parec/PyAudio MonitorRecorder.) Device selection is dynamic — read from voice.audio_devices on each refresh. """ @@ -49,7 +52,6 @@ from Project.Sanad.config import ( RECEIVE_SAMPLE_RATE, SINK as DEFAULT_SINK, SOURCE as DEFAULT_SOURCE, - MONITOR_SOURCE, ) from Project.Sanad.core.logger import get_logger from Project.Sanad.voice import audio_devices as ad @@ -58,10 +60,18 @@ log = get_logger("audio_manager") FORMAT = pyaudio.paInt16 if pyaudio else 8 -# Cached current selection — updated by refresh_devices() -_DEVICE_LOCK = threading.Lock() -_current_sink = DEFAULT_SINK -_current_source = DEFAULT_SOURCE +# Default fallback constants only — the live selection is per-instance state +# on AudioManager (self._current_sink / self._current_source), guarded by +# self._device_lock. Keeping the selection module-global meant two +# AudioManager instances stomped each other's sink/source; it now lives on +# the instance. + +# How long an applied pactl selection is trusted before the hot playback / +# recording path re-runs the (expensive, multi-shell) pactl scan. The +# audio_devices watcher and the dashboard Apply endpoint already re-resolve +# on device change, so a short TTL here is purely a backstop against an +# unobserved hot-unplug — it does NOT need to be tight. +_DEFAULTS_TTL_S = 5.0 def _run_pactl(args: list[str]) -> subprocess.CompletedProcess[str]: @@ -80,26 +90,6 @@ def _resolve_devices() -> tuple[str, str]: return DEFAULT_SINK, DEFAULT_SOURCE -def ensure_audio_defaults(): - """Re-scan all USB ports, resolve the active profile, set pactl defaults. - - This is called at startup AND before every playback/recording so that - even if the user unplugs/re-plugs a device into a different port, the - correct sink/source is always used. - """ - try: - result = ad.apply_current_selection() - cur = result.get("selection", {}) - sink = cur.get("sink", "") - source = cur.get("source", "") - with _DEVICE_LOCK: - global _current_sink, _current_source - _current_sink = sink or DEFAULT_SINK - _current_source = source or DEFAULT_SOURCE - except Exception as exc: - log.warning("Audio defaults not applied: %s", exc) - - class _PulseOpenFailed(RuntimeError): """Signal from `_play_pcm_via_pulse` that PortAudio refused to open the output stream (sink gone, bad I/O combination, etc.) — lets `play_wav` @@ -116,6 +106,19 @@ class AudioManager: ) self.pya = pyaudio.PyAudio() self.play_lock = threading.Lock() + # Per-instance device selection (was module-global — two + # AudioManagers used to share one sink/source and stomp each + # other). _device_lock guards _current_sink / _current_source. + self._device_lock = threading.Lock() + self._current_sink = DEFAULT_SINK + self._current_source = DEFAULT_SOURCE + # Throttle ensure_audio_defaults() on the hot path — monotonic ts of + # the last successful apply. 0.0 = never applied yet. + self._defaults_applied_at = 0.0 + # Cached PortAudio device index for the 'pulse'/'default' device + # (None = not probed; -1 = probed, absent). Lets play_pcm/record_mic + # route through PulseAudio instead of PortAudio's silent hw:0 default. + self._pulse_pa_index: int | None = None # Lazily-initialised G1 DDS audio client (for play_wav → chest speaker) self._g1_audio_client: Any = None # G1 playback state — present during an active play_wav() call, @@ -123,9 +126,17 @@ class AudioManager: # from other threads while _play_pcm_via_g1 holds play_lock. self._play_state_lock = threading.Lock() self._play_state: dict[str, Any] | None = None + # Monotonic play id — a new play_wav bumps it to preempt the in-flight + # one (so playing a record interrupts the previous instead of queueing). + self._play_epoch = 0 + # Manual "hold" for the live Gemini pause. Default False = AUTO (record + # playback pauses Gemini only for the clip, then resumes). When True, the + # live voice is paused and STAYS paused (record playback won't resume it) + # until the dashboard releases the hold. Set via set_live_voice_hold(). + self._live_voice_hold = False # Resolve devices and set PulseAudio defaults at startup self.refresh_devices() - ensure_audio_defaults() + self.ensure_audio_defaults(force=True) def _get_g1_audio_client(self): """Return a cached G1 AudioClient (DDS) — creates on first use. @@ -140,7 +151,14 @@ class AudioManager: return self._g1_audio_client try: c = AudioClient() - c.SetTimeout(5.0) + # SHORT RPC timeout (was 5.0). The G1 "voice" service replies to RPCs + # on a topic SHARED with the live-voice child's AudioClient; when both + # run, the dashboard's reply ack is frequently lost in the collision, + # so _Call would block the FULL timeout per STOP/PlayStream — that was + # the "5s delay / no sound". The request itself is still published + # (audio plays); we don't need the ack, so fail fast. Good-case replies + # arrive in ~0.1s, so 0.6s keeps the happy path while killing the hang. + c.SetTimeout(0.6) c.Init() try: c.SetVolume(100) @@ -156,23 +174,109 @@ class AudioManager: def refresh_devices(self) -> dict[str, str]: """Re-read selected sink/source from audio_devices module.""" sink, source = _resolve_devices() - with _DEVICE_LOCK: - global _current_sink, _current_source - _current_sink, _current_source = sink, source + with self._device_lock: + self._current_sink, self._current_source = sink, source log.info("AudioManager devices refreshed: sink=%s source=%s", sink, source) return {"sink": sink, "source": source} + def ensure_audio_defaults(self, force: bool = False) -> None: + """Re-scan all USB ports, resolve the active profile, set pactl defaults. + + Called at startup AND before playback/recording so that even if the + user unplugs/re-plugs a device into a different port, the correct + sink/source is always used. + + The scan (ad.apply_current_selection → current_selection → + detect_plugged_profiles) shells out to pactl many times, so on the + hot playback/record path we skip it when it ran within + `_DEFAULTS_TTL_S`. Pass `force=True` (startup / device-change) to + bypass the throttle. + """ + if not force: + with self._device_lock: + if (time.monotonic() - self._defaults_applied_at) < _DEFAULTS_TTL_S: + return + try: + result = ad.apply_current_selection() + cur = result.get("selection", {}) + sink = cur.get("sink", "") + source = cur.get("source", "") + with self._device_lock: + self._current_sink = sink or DEFAULT_SINK + self._current_source = source or DEFAULT_SOURCE + self._defaults_applied_at = time.monotonic() + # At startup / device-change, re-apply the user's SAVED speaker volume + # to the active sink — PulseAudio doesn't persist our USB/BT (JBL/Anker) + # sink volume across restarts, so without this the JBL comes back at a + # default level instead of where the user left it. + if force: + self._restore_sink_volume() + except Exception as exc: + log.warning("Audio defaults not applied: %s", exc) + + def _restore_sink_volume(self) -> None: + """Apply config audio.g1_volume to the active PulseAudio sink.""" + try: + from Project.Sanad.config import load_config + vol = int(((load_config() or {}).get("audio") or {}).get("g1_volume", 100)) + vol = max(0, min(100, vol)) + sink = self._current_sink or "@DEFAULT_SINK@" + import subprocess as _sp + _sp.run(["pactl", "set-sink-volume", sink, "%d%%" % vol], + timeout=3, check=False, + stdout=_sp.DEVNULL, stderr=_sp.DEVNULL) + if vol > 0: + _sp.run(["pactl", "set-sink-mute", sink, "0"], timeout=3, + check=False, stdout=_sp.DEVNULL, stderr=_sp.DEVNULL) + log.info("restored saved speaker volume → %d%% (sink=%s)", vol, sink) + except Exception as exc: + log.warning("restore sink volume failed: %s", exc) + + def _pulse_device_index(self) -> int | None: + """Resolve the PortAudio device index that routes through PulseAudio. + + On this Jetson's conda PyAudio, opening with output/input device + index None lands on PortAudio's default — the silent hw:0 + platform-sound card. Opening PortAudio's 'pulse' (or 'default') + device instead routes through the PulseAudio daemon, which + ensure_audio_defaults() has already pointed at the resolved + sink/source. Mirrors voice/audio_io.py's _resolve_device_index. + + Returns the device index, or None when PortAudio exposes no + pulse/default device (then the caller falls back to PortAudio's + own default). Cached for the lifetime of the PyAudio handle. + """ + if self._pulse_pa_index is not None: + return self._pulse_pa_index if self._pulse_pa_index >= 0 else None + pulse_idx = default_idx = None + try: + for i in range(self.pya.get_device_count()): + info = self.pya.get_device_info_by_index(i) + name_lower = str(info.get("name", "")).lower() + if pulse_idx is None and name_lower == "pulse": + pulse_idx = i + elif default_idx is None and name_lower == "default": + default_idx = i + except Exception as exc: + log.debug("pulse device probe failed: %s", exc) + idx = pulse_idx if pulse_idx is not None else default_idx + self._pulse_pa_index = idx if idx is not None else -1 + return idx + @property def current_sink(self) -> str: - with _DEVICE_LOCK: - return _current_sink + with self._device_lock: + return self._current_sink @property def current_source(self) -> str: - with _DEVICE_LOCK: - return _current_source + with self._device_lock: + return self._current_source def close(self): + # Cached PortAudio device index is tied to this PyAudio handle — + # invalidate it so a re-init (audio reset) re-probes 'pulse'. + self._pulse_pa_index = None self.pya.terminate() def sample_width(self) -> int: @@ -182,12 +286,16 @@ class AudioManager: def play_pcm(self, pcm_bytes: bytes, channels: int, sample_rate: int, sample_width: int): with self.play_lock: - ensure_audio_defaults() + self.ensure_audio_defaults() + # Route through PortAudio's 'pulse' device so playback reaches + # the resolved sink — output_device_index=None defaults to the + # silent hw:0 platform-sound card on this Jetson's conda PyAudio. stream = self.pya.open( format=self.pya.get_format_from_width(sample_width), channels=channels, rate=sample_rate, output=True, + output_device_index=self._pulse_device_index(), frames_per_buffer=CHUNK_SIZE, ) try: @@ -201,7 +309,10 @@ class AudioManager: # Sink-name substrings that mean "PulseAudio routes this somewhere # audible without DDS" — extend the tuple to add more USB cards (e.g. # hollyland sink). Matched case-insensitively. - _PULSE_SINK_MARKERS = ("anker", "powerconf", "hollyland") + # "jbl"/"bluez" → the JBL Bluetooth speaker (and any bluez sink) is a real + # PulseAudio sink, so record playback must go via paplay/PulseAudio, NOT the + # G1 DDS chest speaker. + _PULSE_SINK_MARKERS = ("anker", "powerconf", "hollyland", "jbl", "bluez") # Sample rate Anker PowerConf (and most USB UAC1 cards) accept natively # — used as the resample target before opening a PortAudio stream so # we don't hit paInvalidSampleRate when the WAV's native rate @@ -349,9 +460,59 @@ class AudioManager: return {"path": str(path), "duration_seconds": round(duration, 3), "route": route, "sink": sink or "default"} + def _set_live_voice_paused(self, paused: bool) -> None: + """Pause/resume the live Gemini session around a record playback so it + doesn't talk over (or react to) the clip. Best-effort + lazy import to + avoid a hard dependency on the dashboard process; no-op if the live + subprocess isn't running. + + Runs on a DETACHED daemon thread: the pause is sent over the child's + stdin pipe, and when the child is busy (e.g. mid-reconnect) that write + can block. We must NEVER let it stall the playback loop — which calls + this right before streaming — or the record goes silent. Fire-and-forget + keeps playback starting immediately; a slightly late pause is harmless.""" + def _do() -> None: + try: + from Project.Sanad.main import live_sub + if (live_sub is not None and hasattr(live_sub, "send_pause") + and hasattr(live_sub, "is_running") + and live_sub.is_running()): + live_sub.send_pause(paused) + except Exception: + pass + threading.Thread(target=_do, name="live-voice-pause", daemon=True).start() + + def set_live_voice_hold(self, hold: bool) -> bool: + """Manual hold for the live-Gemini pause. + + hold=True → pause the live voice NOW and keep it paused; record playback + will not auto-resume it (the finally skips the resume). + hold=False → release: resume the live voice, unless a clip is currently + playing (that play's own finally resumes when it ends). + Returns the resulting hold state. Idempotent.""" + self._live_voice_hold = bool(hold) + if self._live_voice_hold: + self._set_live_voice_paused(True) + else: + with self._play_state_lock: + playing = self._play_state is not None + if not playing: + self._set_live_voice_paused(False) + log.info("live-voice hold → %s", "PAUSED" if self._live_voice_hold else "AUTO") + return self._live_voice_hold + # -- G1 DDS-routed playback -- _G1_STREAM_APP = "sanad_playback" + # The live Gemini voice streams to the SAME G1 chest speaker under a + # DIFFERENT app_name (config/voice_config.json speaker.app_name, default + # "sanad"). The G1 "voice" audio service is per-app-name, so a record must + # STOP that app too — otherwise Gemini's chunked PlayStream("sanad", …) per + # spoken word keeps stomping the record's single PlayStream and the clip is + # silent while its counter ticks. STOP_PLAY is process-agnostic (keyed only + # by app_name on the shared DDS "voice" service), so stopping it from here + # halts the separate voice child's stream. Must match voice_config.json. + _LIVE_VOICE_APP = "sanad" _G1_HW_RATE = 16_000 def stop_playback(self) -> None: @@ -404,6 +565,25 @@ class AudioManager: self._play_state.get("record_name") or "?") return {"ok": True, "resumed": True} + def seek_playback(self, position_sec: float) -> dict[str, Any]: + """Jump to `position_sec` in the active clip. The play loop re-pushes + pcm[pos:] from the new position (works whether playing or paused — if + paused, the new position takes effect on resume).""" + with self._play_state_lock: + if self._play_state is None: + return {"ok": False, "reason": "nothing playing"} + rate = self._play_state["rate"] or 1 + total = self._play_state["total_samples"] + target = max(0, min(total, int(float(position_sec) * rate))) + self._play_state["pos"] = target + self._play_state["play_started_pos"] = target + self._play_state["play_started_at"] = 0.0 # park until re-push + self._play_state["seek"] = True + log.info("Playback seek → %.2fs (record=%s)", + target / rate, self._play_state.get("record_name") or "?") + return {"ok": True, "position_sec": round(target / rate, 2), + "duration_sec": round(total / rate, 2) if rate else 0.0} + def playback_status(self) -> dict[str, Any]: """Snapshot of the current playback for the dashboard. Returns `playing=False` when idle. `position_sec` is best-effort — @@ -411,7 +591,8 @@ class AudioManager: with self._play_state_lock: if self._play_state is None: return {"playing": False, "paused": False, "record_name": None, - "position_sec": 0.0, "duration_sec": 0.0} + "position_sec": 0.0, "duration_sec": 0.0, + "live_hold": self._live_voice_hold} rate = self._play_state["rate"] or 1 total = self._play_state["total_samples"] pos = self._play_state["pos"] @@ -426,6 +607,7 @@ class AudioManager: "record_name": self._play_state.get("record_name"), "position_sec": round(pos / rate, 2), "duration_sec": round(total / rate, 2) if rate else 0.0, + "live_hold": self._live_voice_hold, } def _play_pcm_via_g1(self, pcm_bytes: bytes, channels: int, @@ -458,27 +640,47 @@ class AudioManager: rate = self._G1_HW_RATE total_samples = len(arr) + # Preempt any in-flight playback: signal it to stop + bump the epoch so + # a NEW play starts promptly instead of queueing behind the previous + # clip (or blocking forever on a paused one). This is what makes + # "play another record" interrupt-and-start rather than stall. with self._play_state_lock: - self._play_state = { - "record_name": record_name, - "rate": rate, - "total_samples": total_samples, - "pos": 0, - "paused": False, - "stop": False, - "play_started_at": 0.0, - "play_started_pos": 0, - } + if self._play_state is not None: + self._play_state["stop"] = True + self._play_epoch += 1 + my_epoch = self._play_epoch - # play_lock serialises overlapping play_wav() calls; pause/resume/stop - # do NOT take it (they only touch _play_state under _play_state_lock). + # play_lock serialises overlapping play_wav() calls; the preempted + # playback (stop=True) releases it promptly. pause/resume/stop do NOT + # take it (they only touch _play_state under _play_state_lock). with self.play_lock: + # State is set INSIDE the lock now (was before — which let a second + # play stomp the first's state). Bail if a still-newer play won the + # race while we waited for the lock. + with self._play_state_lock: + if my_epoch != self._play_epoch: + return + self._play_state = { + "record_name": record_name, + "rate": rate, + "total_samples": total_samples, + "pos": 0, + "paused": False, + "stop": False, + "seek": False, + "play_started_at": 0.0, + "play_started_pos": 0, + "epoch": my_epoch, + } + # Pause the live Gemini for the clip (idempotent across preempting + # plays; the last play's finally resumes it). + self._set_live_voice_paused(True) try: while True: # Snapshot the state for this iteration with self._play_state_lock: st = self._play_state - if st is None or st["stop"]: + if st is None or st.get("epoch") != my_epoch or st["stop"]: break if st["paused"]: paused_now = True @@ -486,19 +688,29 @@ class AudioManager: sub_total_sec = 0.0 else: paused_now = False + st["seek"] = False # consumed — pushing from st["pos"] pos = st["pos"] if pos >= total_samples: break sub_bytes = arr[pos:].tobytes() sub_total_sec = (total_samples - pos) / rate st["play_started_pos"] = pos - st["play_started_at"] = time.time() + # Set for real only AFTER PlayStream fires (below) so + # the dashboard counter doesn't tick on a stream that + # was dropped/never started. 0.0 → playback_status + # parks at play_started_pos until audio truly begins. + st["play_started_at"] = 0.0 if paused_now: time.sleep(0.1) continue - # Push remainder to G1 + # Push remainder to G1. A SINGLE STOP suffices: the G1 "voice" + # service treats the chest speaker as one stream and STOP_PLAY + # is global (stops whatever's playing regardless of app_name), + # so this also clears any Gemini stream. Two STOP RPCs doubled + # the latency on the shared DDS bus and stalled the start; the + # live-voice pause (child stops its own stream) covers Gemini. stream_id = f"wav_{int(time.time() * 1000)}" try: client._Call( @@ -508,7 +720,35 @@ class AudioManager: except Exception: pass time.sleep(0.15) - client.PlayStream(self._G1_STREAM_APP, stream_id, sub_bytes) + # After the STOP+settle window, re-check our state: bail if a + # newer press superseded us (no churn / no queue), or loop back + # if a Pause was clicked during the window (don't leak audio). + with self._play_state_lock: + st = self._play_state + if st is None or st.get("epoch") != my_epoch or st["stop"]: + break + paused_in_settle = st["paused"] + if paused_in_settle: + continue + # PlayStream can raise on a DDS hiccup; if it does, abort this + # play rather than leaving play_started_at=0 while the poll loop + # runs (which would make the pause-math elapsed huge and snap + # the counter to the end). Set the timestamp only on success. + try: + client.PlayStream(self._G1_STREAM_APP, stream_id, sub_bytes) + except Exception as exc: + log.warning("PlayStream failed: %s", exc) + break + with self._play_state_lock: + if (self._play_state is not None + and self._play_state.get("epoch") == my_epoch): + self._play_state["play_started_at"] = time.time() + # NOTE: do NOT issue a STOP_PLAY here. The G1 "voice" service + # treats the chest speaker as a SINGLE stream — STOP_PLAY halts + # whatever is currently playing regardless of app_name (verified + # empirically: a post-PlayStream STOP("sanad") silenced the + # record entirely). The pre-stream STOP(both) above already + # cleared Gemini; the live-voice pause keeps it from re-pushing. # Poll for pause / stop while the clip drains poll_deadline = time.time() + sub_total_sec + 0.3 @@ -525,6 +765,19 @@ class AudioManager: except Exception: pass break + if self._play_state.get("seek"): + # Seek requested — halt the current stream and let + # the outer loop re-push from the new pos (already + # set by seek_playback). Cleared in the push branch. + try: + client._Call( + ROBOT_API_ID_AUDIO_STOP_PLAY, + json.dumps({"app_name": self._G1_STREAM_APP}), + ) + except Exception: + pass + interrupted = True + break if self._play_state["paused"]: # Halt G1 and snapshot the new position try: @@ -560,7 +813,19 @@ class AudioManager: break finally: with self._play_state_lock: - self._play_state = None + # Only clear if it's still OURS — a preempting play may have + # already installed its own state after bumping the epoch. + mine = (self._play_state is not None + and self._play_state.get("epoch") == my_epoch) + if mine: + self._play_state = None + # Resume the live Gemini only if WE were the last play — if a + # newer play preempted us, it keeps Gemini paused and will + # resume when it finishes (no pause/resume thrash on rapid clicks). + # Skip the resume entirely while a manual hold is active: the user + # wants Gemini to STAY paused until they release it. + if mine and not self._live_voice_hold: + self._set_live_voice_paused(False) # paplay binary path. Cached on first probe so we don't keep re-shelling # `which paplay` on every play_wav call. None = probe pending; "" = absent. @@ -774,8 +1039,9 @@ class AudioManager: pause / stop latency is bounded. """ # Make sure pactl defaults reflect the current selection — this is - # a no-op when the watcher or dashboard Apply already aligned them. - ensure_audio_defaults() + # a no-op when the watcher or dashboard Apply already aligned them + # (throttled so the multi-shell pactl scan doesn't run per clip). + self.ensure_audio_defaults() # Resample to a USB-native rate before opening the stream. # PortAudio's ALSA backend (the one PyAudio uses) opens the underlying @@ -849,6 +1115,7 @@ class AudioManager: channels=channels, rate=sample_rate, output=True, + output_device_index=self._pulse_device_index(), frames_per_buffer=CHUNK_SIZE, ) except Exception as exc: @@ -903,13 +1170,17 @@ class AudioManager: # -- recording -- def record_mic(self, duration_sec: float) -> bytes: - """Record from default mic for *duration_sec* seconds, return raw PCM.""" - ensure_audio_defaults() + """Record from the resolved mic for *duration_sec* seconds, return raw PCM.""" + self.ensure_audio_defaults() + # Capture through PortAudio's 'pulse' device so we read the resolved + # default source — input_device_index=None defaults to the silent + # hw:0 platform-sound card on this Jetson's conda PyAudio. stream = self.pya.open( format=FORMAT, channels=CHANNELS, rate=RECEIVE_SAMPLE_RATE, input=True, + input_device_index=self._pulse_device_index(), frames_per_buffer=CHUNK_SIZE, ) frames: list[bytes] = [] diff --git a/voice/live_voice_loop.py b/voice/live_voice_loop.py index e22ffae..c95b075 100644 --- a/voice/live_voice_loop.py +++ b/voice/live_voice_loop.py @@ -81,8 +81,18 @@ class LiveVoiceLoop: self.sanad_arm: Any = None self._load_dispatch() - # Snapshot of already-processed transcript lines so we don't re-fire - self._seen_transcripts: set[str] = set() + # Guards the cross-thread transcript cursor + pending-trigger state. + # set_trigger_enabled() runs on the FastAPI event-loop thread while the + # poll daemon mutates the same fields — without this lock a concurrent + # update can tear the cursor / pending flags. + self._trigger_lock = threading.Lock() + + # Transcript consumption is tracked by POSITION, not by content, so a + # repeated identical command (e.g. "wave" said twice) re-fires. We + # remember the last deque snapshot we processed and only dispatch the + # newly-appended tail. (live_sub.user_transcript is an append-only, + # left-evicting deque, so the new lines are always at the right.) + self._last_snapshot: list[str] = [] # ── phrase dispatch loader ──────────────────────────────────── def _load_dispatch(self): @@ -96,6 +106,7 @@ class LiveVoiceLoop: # reach `wake_dispatch`, so a matched phrase for one of those # silently no-ops in voice mode. sdk_only_options = [o for o in OPTION_LIST if not getattr(o, "file", "")] + filtered_out = [o.name for o in OPTION_LIST if getattr(o, "file", "")] if SANAD_ARM_TXT.exists(): self.wake_dispatch = load_arm_phrase_dispatch(SANAD_ARM_TXT, sdk_only_options) log.info( @@ -104,14 +115,67 @@ class LiveVoiceLoop: len(self.wake_dispatch), SANAD_ARM_TXT.name, len(sdk_only_options), len(OPTION_LIST), ) + # Make the silent no-op observable: file-backed replays + # (laugh/bird/change_battery/move_*) are excluded from voice + # dispatch, so a spoken phrase for one of these does nothing. + if filtered_out: + log.warning( + "voice arm dispatch EXCLUDES %d file-backed action(s) " + "(dashboard-only, no voice trigger): %s", + len(filtered_out), ", ".join(filtered_out), + ) else: log.warning("sanad_arm.txt missing at %s — arm trigger disabled", SANAD_ARM_TXT) + # Fold in operator-editable WakePhraseManager entries so dashboard + # CRUD (data/wake_phrases.json) actually affects voice triggering. + self._merge_wake_phrases() except Exception as exc: log.warning("arm dispatch unavailable: %s", exc) self.sanad_arm = None self.wake_dispatch = {} + def _merge_wake_phrases(self) -> None: + """Merge WakePhraseManager phrases into wake_dispatch. + + Without this, dashboard wake-phrase edits were a silent no-op — the + manager was stored on self.wake_mgr but never consulted. We only fold + in entries whose action_id resolves unambiguously to a voice-eligible + (SDK-only, non file-backed) arm option, matching either the integer id + or the option name; anything else is skipped with a warning so a + mistyped/file-backed action can't misfire the arm.""" + if self.wake_mgr is None: + return + try: + from Project.Sanad.motion.sanad_arm_controller import ( + OPTION_BY_ID, OPTION_BY_NAME, + ) + amap = self.wake_mgr.action_phrase_map() + except Exception as exc: + log.warning("wake_phrase merge unavailable: %s", exc) + return + merged = skipped = 0 + for action_id_str, phrases in amap.items(): + opt = None + key = str(action_id_str).strip() + # Resolve by integer id first, then by option name. + if key.isdigit(): + opt = OPTION_BY_ID.get(int(key)) + if opt is None: + opt = OPTION_BY_NAME.get(key.lower()) + if opt is None or getattr(opt, "file", ""): + # Unknown action, or a file-backed replay (voice-excluded). + skipped += 1 + log.warning("wake phrase action %r not voice-eligible — skipped", + action_id_str) + continue + bucket = self.wake_dispatch.setdefault(opt.id, set()) + bucket.update(p for p in phrases if p) + merged += 1 + if merged or skipped: + log.info("merged WakePhraseManager entries (%d actions merged, %d skipped)", + merged, skipped) + # ── lifecycle ──────────────────────────────────────────────── async def start(self) -> None: if self._running: @@ -145,24 +209,27 @@ class LiveVoiceLoop: """ self.trigger_enabled = bool(enabled) - # Drop pending fallback timer — a queued "fire in 0.6s" from - # before the toggle must not leak across. - self.state._pending_arm_wave = False - self.state._pending_arm_wave_fired = False - self.state._pending_arm_trigger_fn = None - self.state._pending_arm_fallback_time = 0.0 + with self._trigger_lock: + # Drop pending fallback timer — a queued "fire in 0.6s" from + # before the toggle must not leak across. + self.state._pending_arm_wave = False + self.state._pending_arm_wave_fired = False + self.state._pending_arm_trigger_fn = None + self.state._pending_arm_fallback_time = 0.0 - snapshotted = 0 - if self.trigger_enabled: - try: - from Project.Sanad.main import live_sub - if live_sub is not None: - for txt in list(live_sub.user_transcript): - if txt and txt not in self._seen_transcripts: - self._seen_transcripts.add(txt) - snapshotted += 1 - except Exception as exc: - log.warning("set_trigger_enabled: snapshot failed: %s", exc) + snapshotted = 0 + if self.trigger_enabled: + # On enable, mark everything currently in the deque as already + # consumed (by position) so phrases said while the gate was off + # don't suddenly fire when it's turned back on. Only NEW speech + # after this moment will dispatch. + try: + from Project.Sanad.main import live_sub + if live_sub is not None: + self._last_snapshot = list(live_sub.user_transcript) + snapshotted = len(self._last_snapshot) + except Exception as exc: + log.warning("set_trigger_enabled: snapshot failed: %s", exc) log.info("trigger_enabled=%s (pending cleared, %d transcripts marked seen)", self.trigger_enabled, snapshotted) @@ -186,27 +253,51 @@ class LiveVoiceLoop: # Master gate — same check as _dispatch if not self.trigger_enabled: return - if not getattr(self.state, "_pending_arm_wave", False): - return - if getattr(self.state, "_pending_arm_wave_fired", False): - return - fn = getattr(self.state, "_pending_arm_trigger_fn", None) - if fn is None: - return - fallback_at = float(getattr(self.state, "_pending_arm_fallback_time", 0.0) or 0.0) - if fallback_at <= 0.0 or time.time() < fallback_at: - return - # Gate on arm idle — skip fire if a motion is already running - if self.sanad_arm is not None and getattr(self.sanad_arm, "_is_busy", False): - return + # Read-and-claim the pending trigger under the lock so a concurrent + # set_trigger_enabled() (FastAPI thread) can't clear it mid-fire and + # cause a stray or lost deferred arm action. + with self._trigger_lock: + if not getattr(self.state, "_pending_arm_wave", False): + return + if getattr(self.state, "_pending_arm_wave_fired", False): + return + fn = getattr(self.state, "_pending_arm_trigger_fn", None) + if fn is None: + return + fallback_at = float(getattr(self.state, "_pending_arm_fallback_time", 0.0) or 0.0) + if fallback_at <= 0.0 or time.time() < fallback_at: + return + # Gate on arm idle — skip fire if a motion is already running + if self.sanad_arm is not None and getattr(self.sanad_arm, "_is_busy", False): + return + # Claim it now (still under the lock) so it fires exactly once. + self.state._pending_arm_wave_fired = True + self.state._pending_arm_wave = False + self.state._pending_arm_trigger_fn = None try: fn() except Exception as exc: log.warning("deferred arm trigger failed: %s", exc) - finally: - self.state._pending_arm_wave_fired = True - self.state._pending_arm_wave = False - self.state._pending_arm_trigger_fn = None + + @staticmethod + def _new_tail(prev: list[str], curr: list[str]) -> list[str]: + """Return the items appended to ``curr`` since ``prev`` was taken. + + ``curr`` is a snapshot of an append-only, left-evicting deque. The new + lines are the suffix of ``curr`` that wasn't present at the end of + ``prev``. We find the largest overlap k where the tail of ``prev`` + equals the head of ``curr`` and return everything after it. This is + position-based (not content-based), so a repeated identical command is + treated as a genuinely new line and re-fires.""" + if not prev: + return list(curr) + max_k = min(len(prev), len(curr)) + for k in range(max_k, 0, -1): + if prev[-k:] == curr[:k]: + return list(curr[k:]) + # No overlap — the buffer rolled over entirely between polls; treat the + # whole current snapshot as new. + return list(curr) def _check_transcripts(self): try: @@ -215,24 +306,33 @@ class LiveVoiceLoop: return if live_sub is None: return - # Pull recent transcripts - for text in list(live_sub.user_transcript): - if text in self._seen_transcripts: - continue - self._seen_transcripts.add(text) + curr = list(live_sub.user_transcript) + with self._trigger_lock: + new_lines = self._new_tail(self._last_snapshot, curr) + self._last_snapshot = curr + # Dispatch only the newly-appended tail (outside the lock — _dispatch + # may spawn an arm replay thread). + for text in new_lines: self.last_heard = text self._dispatch(text) - # Prune seen set when subprocess stops to free memory - if not live_sub.is_running() and len(self._seen_transcripts) > 500: - self._seen_transcripts.clear() - def _dispatch(self, transcript_text: str) -> None: if not self.wake_dispatch or self.sanad_arm is None: return # Master gate — skip arm triggering entirely when disabled if not self.trigger_enabled: return + # Arm ⇄ locomotion interlock — refuse voice gestures while the robot + # may be walking. The authoritative check is sanad_arm._blocked() at + # fire time (covers the deferred path too); refuse early here so the + # block is observable and we don't queue a deferred fire that would be + # silently dropped later. + try: + if self.sanad_arm._blocked(): + log.info("arm trigger refused — locomotion active (movement_active)") + return + except Exception: + pass # Gate trigger on arm idle if getattr(self.sanad_arm, "_is_busy", False): return diff --git a/voice/movement_dispatch.py b/voice/movement_dispatch.py index e712f54..d9d010c 100644 --- a/voice/movement_dispatch.py +++ b/voice/movement_dispatch.py @@ -327,6 +327,13 @@ class MovementDispatcher: # after the operator just toggled movement off. if cmd != "stop" and not self._movement_enabled(force=True): continue # toggled off while queued — drop + # Never step while Nav2 owns the legs (autonomous goal in progress). + # Two stacks driving at once is the exact hazard _arbiter guards. + # STOP always passes through (safety). Read-only check — manual loco + # uses acquire_loco; the discrete-step voice path must only YIELD. + if cmd != "stop" and self._nav_holds_legs(): + log.info("voice movement dropped — Nav2 owns the legs (%r)", cmd) + continue try: self._execute(cmd) except Exception: @@ -363,9 +370,21 @@ class MovementDispatcher: return log.debug("no loco mapping for canonical %r", c) + @staticmethod + def _nav_holds_legs() -> bool: + """True if Nav2 currently owns the legs (in-process arbiter). Lazy + import so a missing/absent dashboard package never breaks voice.""" + try: + from Project.Sanad.dashboard.routes import _arbiter + return _arbiter.nav_active() + except Exception: + return False + def _repeat_step(self, direction: str, n: int): for _ in range(max(1, n)): - if self._abort.is_set() or self._estop or not self._movement_enabled(force=True): + if (self._abort.is_set() or self._estop + or not self._movement_enabled(force=True) + or self._nav_holds_legs()): log.info("voice multi-step aborted") break self._loco.step(direction) diff --git a/voice/sanad_voice.py b/voice/sanad_voice.py index e0524d0..dc64fc5 100644 --- a/voice/sanad_voice.py +++ b/voice/sanad_voice.py @@ -34,6 +34,7 @@ import json import logging import os import sys +import tempfile import threading import time import types @@ -145,12 +146,12 @@ _MOVEMENT_PROMPT_RULES = ( "(you receive a [MOVEMENT ON] or [MOVEMENT STATUS] note). When movement is " "OFF, never confirm a motion — tell the user to enable movement from the " "dashboard.\n" - "When movement is ON and the user addresses you by name (Bousandah / بوسنده) AND " + "When movement is ON and the user addresses you by name (Sanad iron man / سند) AND " "asks you to move, reply with ONE short confirmation phrase per requested " "motion, in the SAME language, in the order asked. Use these EXACT shapes — " "they are what triggers the motion:\n" - " forward : 'Moving forward.' / 'أمشي للأمام.'\n" - " backward : 'Moving back.' / 'أمشي للخلف.'\n" + " forward : 'Walking forward.' / 'أمشي للأمام.'\n" + " backward : 'Walking back.' / 'أمشي للخلف.'\n" " turn right : 'Turning right.' / 'أستدير يميناً.'\n" " turn left : 'Turning left.' / 'أستدير يساراً.'\n" " slide left : 'Sliding left.' / 'أنزلق لليسار.'\n" @@ -166,6 +167,33 @@ _MOVEMENT_PROMPT_RULES = ( "mis-hear a 0, drop the number and say the bare motion." ) +# Native function-calling: Gemini can DRIVE the robot to saved map places via +# the navigate_to_place / list_places / where_am_i / stop_navigation tools. The +# tool schemas are declared in the Live config; this block tells Gemini WHEN and +# HOW to use them, and the safety constraints. Appended only when nav tools are +# enabled (SANAD_NAV_TOOLS != 0). +_NAV_TOOLS_ENABLED = os.environ.get("SANAD_NAV_TOOLS", "1") != "0" +_NAV_PROMPT_RULES = ( + "\n\n--- NAVIGATION (autonomous driving to places) ---\n" + "You can autonomously DRIVE the robot to a saved place on the loaded map " + "using your tools. This is different from step-by-step walking above.\n" + "- When the user asks to GO/MOVE/TAKE them to a named place (e.g. 'go to " + "the kitchen', 'خذني للاستقبال'), call the navigate_to_place tool with the " + "place name. Do NOT say the walking phrases above for this — the tool does " + "the driving.\n" + "- You can only drive to places that exist in the CURRENTLY loaded map. If " + "you are unsure which places exist, call list_places first and offer them.\n" + "- If the tool returns reason 'no_map', tell the user to load a map first. " + "If 'movement_off', tell them to enable movement. If 'ambiguous' or " + "'unknown_place', read back the candidate names and ask which one.\n" + "- After a successful navigate_to_place, briefly say you're heading there — " + "but do NOT claim you have arrived. You will receive a [NAV ARRIVED] note " + "when you actually arrive (then tell the user), or [NAV FAILED] if you " + "could not reach it (then apologise and say why).\n" + "- To stop an in-progress drive, call stop_navigation.\n" + "Keep all of this in your normal Khaleeji style." +) + def _load_system_prompt() -> str: """scripts/ → config default → hardcoded fallback, with the @@ -175,27 +203,41 @@ def _load_system_prompt() -> str: mismatch (e.g. `persona: "sanad_v2"` while only `sanad_script.txt` existed) which made the robot fall back to the English default that introduces itself as "Sanad" instead of using the Arabic persona on - disk. We now WARN so the same trap doesn't bite again.""" + disk. We now WARN so the same trap doesn't bite again. + + The persona is resolved HERE (at session start), not at import — so the + operator's Scripts Manager selection (a sanad_script_v*.txt variant) is + picked up on the next voice (re)connect. Falls back to sanad_script.txt.""" + # Resolve the selected persona variant (or the default sanad_script.txt). + try: + from Project.Sanad.core.persona import active_persona_path + persona_file = active_persona_path() + except Exception: + persona_file = _PERSONA_FILE base = None try: - text = _PERSONA_FILE.read_text(encoding="utf-8-sig").strip() + text = persona_file.read_text(encoding="utf-8-sig").strip() if text: base = text + log.info("persona loaded: %s", persona_file.name) except FileNotFoundError: log.warning( "Persona file not found at %s — falling back to " "config.core.gemini_defaults.default_system_prompt. " "Check `script_files.persona` in config/core_config.json " - "matches an actual file under scripts/.", _PERSONA_FILE, + "matches an actual file under scripts/.", persona_file, ) except (OSError, UnicodeDecodeError) as exc: # An existing-but-unreadable persona file (bad encoding, permissions, a # directory) must NOT crash the voice child — fall back to the default. log.warning("Persona file at %s could not be read (%s) — " - "falling back to default system prompt.", _PERSONA_FILE, exc) + "falling back to default system prompt.", persona_file, exc) if base is None: base = _GEMINI_DEFAULTS.get("default_system_prompt", _FALLBACK_SYSTEM_PROMPT) - return base + _MOVEMENT_PROMPT_RULES + prompt = base + _MOVEMENT_PROMPT_RULES + if _NAV_TOOLS_ENABLED: + prompt += _NAV_PROMPT_RULES + return prompt def _audio_energy(pcm: bytes) -> int: @@ -323,10 +365,22 @@ class TurnRecorder: payload = {"records": []} payload.setdefault("records", []).append(entry) payload["total_records"] = len(payload["records"]) - idx_path.write_text( - json.dumps(payload, indent=2, ensure_ascii=False), - encoding="utf-8", - ) + # Atomic write (tempfile + os.replace) — an in-place write_text that is + # interrupted (the start_all.sh supervisor Ctrl+C-teardowns this voice + # child) can truncate index.json, so the next read falls back to an + # empty {"records": []} and silently drops all prior turn metadata. + # Mirrors voice/typed_replay._save_index. + fd, tmp = tempfile.mkstemp(dir=str(idx_path.parent), suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(payload, f, indent=2, ensure_ascii=False) + os.replace(tmp, idx_path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise # ─── BRAIN FACTORY ─────────────────────────────────────── diff --git a/voice/typed_replay.py b/voice/typed_replay.py index 0443dbe..b102588 100644 --- a/voice/typed_replay.py +++ b/voice/typed_replay.py @@ -415,12 +415,14 @@ class TypedReplayEngine: if not self.voice_client.connected: await self.voice_client.connect() - # Ordered by empirical reliability — first variant wins ~95% of turns. - # The quoted-phrase form is the most consistent trigger for an - # audio-only response with the current Sanad persona prompt. + # The voice_client now runs the multilingual VERBATIM TTS prompt + # (gemini/client.TTS_SYSTEM_PROMPT), so the text is read back in its own + # language. Send the raw text first (cleanest — no wrapper to bias the + # language); fall back to explicit, language-neutral verbatim asks. The + # old attempt-1 wrapped in Arabic, which dragged every line to Arabic. attempts = [ - f'قل هذا بالضبط وبدون إضافات: "{stripped}"', # Arabic: "Say this exactly, no additions" - f'Say this exactly, nothing else: "{stripped}"', + stripped, + f'Read this aloud word for word, in its original language, nothing else:\n{stripped}', f'"{stripped}"', ] last_parts: list[str] = [] @@ -496,31 +498,35 @@ class TypedReplayEngine: except Exception as exc: log.warning("couldn't switch default source to monitor: %s", exc) - stream = None + # Outer try/finally guarantees the default source is restored even + # if pya.open()/recorder.start()/stream.write() raises — otherwise + # the machine's default mic stays pointed at the speaker monitor. try: - stream = self.audio_mgr.pya.open( - format=pyaudio.paInt16, - channels=CHANNELS, - rate=RECEIVE_SAMPLE_RATE, - output=True, - frames_per_buffer=CHUNK_SIZE, - ) - if recorder is not None: - recorder.start() - frame_bytes = CHUNK_SIZE * 2 - for offset in range(0, len(audio_bytes), frame_bytes): - stream.write(audio_bytes[offset:offset + frame_bytes]) - finally: - if stream is not None: - try: - stream.stop_stream() - finally: - stream.close() + stream = None + try: + stream = self.audio_mgr.pya.open( + format=pyaudio.paInt16, + channels=CHANNELS, + rate=RECEIVE_SAMPLE_RATE, + output=True, + frames_per_buffer=CHUNK_SIZE, + ) + if recorder is not None: + recorder.start() + frame_bytes = CHUNK_SIZE * 2 + for offset in range(0, len(audio_bytes), frame_bytes): + stream.write(audio_bytes[offset:offset + frame_bytes]) + finally: + if stream is not None: + try: + stream.stop_stream() + finally: + stream.close() - captured = b"" - try: + captured = b"" if recorder is not None: captured = recorder.stop() + return captured finally: if restore_source: try: @@ -528,8 +534,6 @@ class TypedReplayEngine: except Exception as exc: log.warning("couldn't restore default source: %s", exc) - return captured - def save_audio(self, pcm: bytes, path: Path, channels: int, rate: int) -> None: with wave.open(str(path), "wb") as wf: wf.setnchannels(channels)