diff --git a/API/audio_api.py b/API/audio_api.py index 4645f37..59e88b8 100644 --- a/API/audio_api.py +++ b/API/audio_api.py @@ -28,22 +28,27 @@ import json import logging import os import subprocess +import sys import threading import time import wave import numpy as np -# ─── PATH CONFIG ───────────────────────────────────────── -from dotenv import load_dotenv -load_dotenv() - -BASE_PATH = os.environ.get("PROJECT_BASE", "/home/unitree") -PROJECT_NAME = "Marcus" -PROJECT_ROOT = os.path.join(BASE_PATH, PROJECT_NAME) +# ─── PATH + CONFIG ─────────────────────────────────────── +# Use the canonical loaders from Core/ so path + config logic lives in one place. +_PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _PROJECT_DIR not in sys.path: + sys.path.insert(0, _PROJECT_DIR) +from Core.env_loader import PROJECT_ROOT +from Core.config_loader import load_config LOG_DIR = os.path.join(PROJECT_ROOT, "logs") os.makedirs(LOG_DIR, exist_ok=True) +# Note: logging.basicConfig() only takes effect on the first call per process. +# If the voice module already configured logging (common path via run_marcus.py), +# this call is a no-op. When audio_api is used standalone, it wires logs to +# logs/voice.log + stderr. logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", @@ -55,12 +60,6 @@ logging.basicConfig( log = logging.getLogger("audio_api") -def load_config(name: str) -> dict: - path = os.path.join(PROJECT_ROOT, "Config", f"config_{name}.json") - with open(path, "r") as f: - return json.load(f) - - # ─── AUDIO API CLASS ───────────────────────────────────── class AudioAPI: @@ -135,11 +134,12 @@ class AudioAPI: Speak `text` in English through the G1 built-in TTS (TtsMaker). Mutes (flushes) the mic during playback so the voice loop doesn't - hear the robot's own voice and transcribe itself. The `lang` - argument is accepted for API compatibility but only "en" plays — - non-ASCII text (Arabic) is rejected by BuiltinTTS. + hear the robot's own voice and transcribe itself. `lang` is kept + in the signature for API compatibility but only `"en"` is accepted + — non-ASCII text (Arabic) is rejected by BuiltinTTS because the + G1 firmware silently maps it to Chinese, which nobody wants. """ - if lang and lang not in ("en", "auto"): + if lang and lang != "en": log.warning("builtin_tts only supports English; got lang=%r — skipping", lang) return if self._tts_engine is None: diff --git a/Autonomous/marcus_autonomous.py b/Autonomous/marcus_autonomous.py index 1cc321f..9640acb 100644 --- a/Autonomous/marcus_autonomous.py +++ b/Autonomous/marcus_autonomous.py @@ -31,7 +31,7 @@ Files saved summary.txt — auto-generated LLaVA summary frames/ — camera captures at interesting points -Import in marcus_llava.py +Import in marcus_brain.py ------------------------- from marcus_autonomous import AutonomousMode auto = AutonomousMode( @@ -303,17 +303,20 @@ class AutonomousMode: def _move_forward(self) -> bool: """ Move forward for FORWARD_DURATION seconds. - Returns True if blocked (no actual movement detected). + + Returns True if blocked. Obstacle detection is not wired in here — + LiDAR runs at the brain layer (API.lidar_api.obstacle_ahead) and + isn't passed into AutonomousMode today. Extend __init__ with an + optional `obstacle_fn=` hook if you want this loop to stop on + obstacles detected outside YOLO's view. """ - # TODO: integrate LiDAR when available for true obstacle detection - # For now: move and assume not blocked t0 = time.time() while time.time() - t0 < FORWARD_DURATION and self._enabled: self._send_vel(vx=0.25) time.sleep(0.05) self._send_vel(0, 0, 0) time.sleep(0.1) - return False # Not blocked — no LiDAR yet + return False def _turn(self, direction: str, duration: float): """Turn left or right for given duration.""" diff --git a/Brain/marcus_brain.py b/Brain/marcus_brain.py index 86c6920..0af1e11 100644 --- a/Brain/marcus_brain.py +++ b/Brain/marcus_brain.py @@ -501,15 +501,19 @@ def run_terminal(): continue if cmd.lower() in ("q", "quit", "exit"): break - if cmd.lower() == "mute/": - import subprocess - subprocess.run(["pactl", "set-source-mute", "0", "1"], capture_output=True) - print(" Mic muted") - continue - if cmd.lower() == "unmute/": - import subprocess - subprocess.run(["pactl", "set-source-mute", "0", "0"], capture_output=True) - print(" Mic unmuted") + if cmd.lower() in ("mute/", "unmute/"): + # Route through the audio API so the action respects whichever + # mic backend is active (BuiltinMic flushes the UDP buffer; + # the legacy pactl path mutes PulseAudio source 3). + if _audio_api is None: + print(" Voice is not initialized") + continue + if cmd.lower() == "mute/": + _audio_api._mute_mic() + print(" Mic muted") + else: + _audio_api._unmute_mic() + print(" Mic unmuted") continue result = process_command(cmd) sp = result.get("speak", "") if isinstance(result, dict) else "" diff --git a/Brain/marcus_memory.py b/Brain/marcus_memory.py index 77eff32..00932c2 100644 --- a/Brain/marcus_memory.py +++ b/Brain/marcus_memory.py @@ -19,7 +19,7 @@ Folder structure alerts.json ← [{time, type, detail}] summary.txt ← auto-generated session summary -Import in marcus_llava.py +Import in marcus_brain.py ------------------------- from marcus_memory import Memory mem = Memory() ← call once at startup diff --git a/Client/marcus_client.py b/Client/marcus_client.py index 0e51aef..64aabdc 100644 --- a/Client/marcus_client.py +++ b/Client/marcus_client.py @@ -14,8 +14,20 @@ import io import numpy as np import websockets -JETSON_IP = "192.168.123.164" -JETSON_PORT = 8765 +# Load network defaults from Config/config_Network.json. Falls back to sane +# hardcoded values if the config isn't reachable (the GUI can run standalone +# from a workstation that isn't on the same filesystem as the project). +_PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _PROJECT_DIR not in sys.path: + sys.path.insert(0, _PROJECT_DIR) +try: + from Core.config_loader import load_config + _net = load_config("Network") + JETSON_IP = _net.get("jetson_ip", "192.168.123.164") + JETSON_PORT = _net.get("websocket_port", 8765) +except Exception: + JETSON_IP = "192.168.123.164" + JETSON_PORT = 8765 # ─── SLAM (runs locally on workstation) ─────────────────────────────────────── SLAM_DIR = os.path.expanduser("~/Robotics_workspace/yslootahtech/G1_Lootah/Lidar") @@ -1000,7 +1012,10 @@ class MarcusGUI: p2 = ImageTk.PhotoImage(img_resized) self.cam_preview.configure(image=p2, text="") self.cam_preview.image = p2 - except Exception: pass + except Exception as e: + # Malformed base64 / torn frame / Tk disposed during shutdown — + # log it so UI freezes become debuggable rather than silent. + print(f" [Client] _update_frame dropped: {type(e).__name__}: {e}") def _update_cam_config(self, d): p, w, h, f = d.get("profile","?"), d.get("width","?"), d.get("height","?"), d.get("fps","?") diff --git a/Navigation/marcus_odometry.py b/Navigation/marcus_odometry.py index d0e097a..f1ac68b 100644 --- a/Navigation/marcus_odometry.py +++ b/Navigation/marcus_odometry.py @@ -9,7 +9,7 @@ Two sources (priority order): 1. ROS2 /dog_odom — joint encoder data → ±2cm accuracy 2. Dead reckoning — velocity × time integration → ±10cm accuracy -Import in marcus_llava.py +Import in marcus_brain.py -------------------------- from marcus_odometry import Odometry odom = Odometry() @@ -78,7 +78,7 @@ class Odometry: def __init__(self): self._lock = threading.Lock() self._alive = [False] - self._sock = None # ZMQ PUB socket (shared from marcus_llava) + self._sock = None # ZMQ PUB socket (shared from marcus_brain) # Position state self._x = 0.0 # meters from start @@ -105,7 +105,7 @@ class Odometry: Start odometry tracking in background thread. Args: - zmq_sock : existing ZMQ PUB socket from marcus_llava.py. + zmq_sock : existing ZMQ PUB socket from marcus_brain.py. If None, creates its own socket on ZMQ_PORT. Passing the shared socket avoids port conflicts. @@ -169,7 +169,7 @@ class Odometry: except zmq.ZMQError as e: if "Address already in use" in str(e): print(f" [Odom] ⚠️ ZMQ port {ZMQ_PORT} already in use.") - print(" [Odom] Pass zmq_sock=sock from marcus_llava.py instead.") + print(" [Odom] Pass zmq_sock=sock from marcus_brain.py instead.") else: print(f" [Odom] ⚠️ ZMQ error: {e}") self._sock = None diff --git a/Vision/marcus_imgsearch.py b/Vision/marcus_imgsearch.py index 9b03908..117784b 100644 --- a/Vision/marcus_imgsearch.py +++ b/Vision/marcus_imgsearch.py @@ -15,7 +15,7 @@ How it works 5. Optional: YOLO pre-filter speeds up search (find person class first, then LLaVA verifies it's the right person) -Usage in marcus_llava.py +Usage in marcus_brain.py ------------------------ from marcus_imgsearch import ImageSearch searcher = ImageSearch(get_frame_fn=get_frame, send_vel_fn=send_vel, @@ -420,11 +420,11 @@ class ImageSearch: # ══════════════════════════════════════════════════════════════════════════════ -# WIRE INTO marcus_llava.py — add to main loop +# WIRE INTO marcus_brain.py — add to main loop # ══════════════════════════════════════════════════════════════════════════════ """ -Add to marcus_llava.py imports: +Add to marcus_brain.py imports: from marcus_imgsearch import ImageSearch Add after Memory init: @@ -504,7 +504,7 @@ if __name__ == "__main__": elif args.image or args.hint: # Real search — needs robot hardware print("Real search requires robot hardware.") - print("Import ImageSearch into marcus_llava.py instead.") + print("Import ImageSearch into marcus_brain.py instead.") print(f" image: {args.image}") print(f" hint: {args.hint}") diff --git a/Vision/marcus_yolo.py b/Vision/marcus_yolo.py index d608b5a..27faf5d 100644 --- a/Vision/marcus_yolo.py +++ b/Vision/marcus_yolo.py @@ -3,7 +3,7 @@ marcus_yolo.py — Marcus Vision Module ======================================= Project : Marcus | YS Lootah Technology Purpose : YOLO-based person + object detection - Import this module in marcus_llava.py — runs as background thread + Import this module in marcus_brain.py — runs as background thread Usage (imported): from marcus_yolo import start_yolo, yolo_sees, yolo_count, yolo_closest, yolo_summary @@ -365,20 +365,20 @@ def _camera_loop(raw_frame_ref, frame_lock, cam_alive): time.sleep(2.0) -# ── Start function — called by marcus_llava.py ──────────────────────────────── +# ── Start function — called by marcus_brain.py ──────────────────────────────── def start_yolo(raw_frame_ref=None, frame_lock=None): """ Start YOLO inference in background thread. - Called automatically by marcus_llava.py during startup. - Shares the camera frame reference from marcus_llava's camera thread. + Called automatically by marcus_brain.py during startup. + Shares the camera frame reference from marcus_brain's camera thread. Args: raw_frame_ref : list[np.ndarray|None] — shared raw BGR frame frame_lock : threading.Lock protecting raw_frame_ref - Example (in marcus_llava.py): + Example (in marcus_brain.py): from marcus_yolo import start_yolo, yolo_sees, yolo_summary start_yolo(raw_frame_ref=_raw_frame, frame_lock=_raw_lock) """ diff --git a/Voice/marcus_voice.py b/Voice/marcus_voice.py index 83124ca..1af2812 100644 --- a/Voice/marcus_voice.py +++ b/Voice/marcus_voice.py @@ -22,23 +22,23 @@ Usage: import logging import os +import sys import threading import time import numpy as np -# ─── PATH CONFIG ───────────────────────────────────────── -from dotenv import load_dotenv -load_dotenv() - -BASE_PATH = os.environ.get("PROJECT_BASE", "/home/unitree") -PROJECT_NAME = "Marcus" -PROJECT_ROOT = os.path.join(BASE_PATH, PROJECT_NAME) - -import json +# ─── PATH + CONFIG ─────────────────────────────────────── +# Single source of truth lives in Core/; everyone else imports from there. +_PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _PROJECT_DIR not in sys.path: + sys.path.insert(0, _PROJECT_DIR) +from Core.env_loader import PROJECT_ROOT +from Core.config_loader import load_config LOG_DIR = os.path.join(PROJECT_ROOT, "logs") os.makedirs(LOG_DIR, exist_ok=True) +# Idempotent — only the first call per process installs handlers. logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", @@ -50,12 +50,6 @@ logging.basicConfig( log = logging.getLogger("marcus_voice") -def load_config(name: str) -> dict: - path = os.path.join(PROJECT_ROOT, "Config", f"config_{name}.json") - with open(path, "r") as f: - return json.load(f) - - # ─── STATE ENUM ────────────────────────────────────────── class State: