"""Macro Player — synchronized playback of audio + motion recordings. Reads paired files: recordings/audio/.wav recordings/motion/.jsonl Plays audio and streams joint commands simultaneously so the robot's physical movements perfectly match the recorded speech timing. """ from __future__ import annotations import json import threading import time import wave from pathlib import Path from typing import Any from Project.Sanad.config import AUDIO_RECORDINGS_DIR, MOTION_RECORDINGS_DIR, REPLAY_HZ from Project.Sanad.core.config_loader import section as _cfg_section from Project.Sanad.core.logger import get_logger log = get_logger("macro_player") _MP = _cfg_section("motion", "macro_player") RAMP_IN_STEPS = _MP.get("ramp_in_steps", 60) RAMP_OUT_STEPS = _MP.get("ramp_out_steps", 60) WATCHDOG_DISABLE_AFTER = _MP.get("watchdog_disable_after_sec", 1.0) def _lerp_q(a: list[float], b: list[float], t: float) -> list[float]: return [x + (y - x) * t for x, y in zip(a, b)] class _ArmAdapter: """Uniform interface over either the public arm API or its private members. Hides the hasattr branching that previously polluted _play_motion. If the arm controller exposes the new public methods (get_current_q, send_frame, disable, state_age, wait_for_state) we use those; otherwise we fall back to the private versions for backward compatibility. """ def __init__(self, arm): self._arm = arm def wait_for_state(self, timeout: float = 2.0) -> bool: fn = getattr(self._arm, "wait_for_state", None) if callable(fn): return fn(timeout=timeout) # No state-wait API: assume ready return True def get_current_q(self) -> list[float]: fn = getattr(self._arm, "get_current_q", None) if callable(fn): return fn() return self._arm._get_current_q() def send_frame(self, target_q: list[float], body_lock_q: list[float]): fn = getattr(self._arm, "send_frame", None) if callable(fn): return fn(target_q, body_lock_q) return self._arm._send_frame(target_q, body_lock_q) def disable(self): fn = getattr(self._arm, "disable", None) if callable(fn): return fn() return self._arm._disable_sdk() def state_age(self) -> float: fn = getattr(self._arm, "state_age", None) if callable(fn): return fn() # No watchdog support: pretend state is fresh return 0.0 class MacroPlayer: def __init__(self, audio_manager=None, arm_controller=None): self._audio_mgr = audio_manager self._arm = arm_controller self._lock = threading.Lock() self._playing = False self._stop_event = threading.Event() @property def is_playing(self) -> bool: return self._playing def play(self, name: str) -> dict[str, Any]: audio_path = AUDIO_RECORDINGS_DIR / f"{name}.wav" motion_path = MOTION_RECORDINGS_DIR / f"{name}.jsonl" if not audio_path.exists(): raise FileNotFoundError(f"Audio not found: {audio_path}") if not motion_path.exists(): raise FileNotFoundError(f"Motion not found: {motion_path}") with self._lock: if self._playing: raise RuntimeError("Macro playback already in progress.") self._playing = True self._stop_event.clear() t0 = time.monotonic() audio_thread = threading.Thread(target=self._play_audio, args=(audio_path,), daemon=True) motion_thread = threading.Thread(target=self._play_motion, args=(motion_path,), daemon=True) log.info("Macro playback starting: %s", name) audio_thread.start() motion_thread.start() audio_thread.join() motion_thread.join() elapsed = time.monotonic() - t0 with self._lock: self._playing = False log.info("Macro playback complete: %s (%.1fs)", name, elapsed) return {"name": name, "duration_sec": round(elapsed, 2)} def stop(self): self._stop_event.set() # Best-effort: stop audio playback if the manager exposes a stop method if self._audio_mgr is not None and hasattr(self._audio_mgr, "stop_playback"): try: self._audio_mgr.stop_playback() except Exception as exc: log.warning("audio stop failed: %s", exc) def _play_audio(self, path: Path): if self._audio_mgr is None: log.warning("No audio manager — skipping audio playback") return try: self._audio_mgr.play_wav(path) except Exception as exc: log.error("Audio playback failed: %s", exc) def _play_motion(self, path: Path): """Stream JSONL motion frames at recorded timing — with ramp-in, watchdog, ramp-out.""" frames = self._load_frames(path) if not frames: return if self._arm is None: # Simulated playback — just sleep through duration = frames[-1].get("t", 0) log.info("[SIM] MacroPlayer would play %d frames over %.1fs", len(frames), duration) time.sleep(min(duration, 30.0)) return adapter = _ArmAdapter(self._arm) interval = 1.0 / REPLAY_HZ # CRITICAL: wait for first LowState before reading current pose if not adapter.wait_for_state(timeout=2.0): log.error("MacroPlayer aborting — no LowState received in 2s") return try: current_q = adapter.get_current_q() except Exception: log.exception("Failed to read current pose") return body_lock_q = list(current_q) first_frame_q = frames[0]["q"] # Phase 1: Ramp-in if not self._ramp(adapter, current_q, first_frame_q, body_lock_q, RAMP_IN_STEPS, "ramp-in"): return # Phase 2: Stream recorded frames with timing + watchdog last_q = self._stream_frames(adapter, frames, body_lock_q, interval) or first_frame_q # Phase 3: Ramp-out back to starting pose self._ramp(adapter, last_q, body_lock_q, body_lock_q, RAMP_OUT_STEPS, "ramp-out") # Phase 4: Disable arm SDK try: adapter.disable() except Exception: log.exception("disable() failed") def _load_frames(self, path: Path) -> list[dict]: """Read JSONL motion file. Returns list of frames or [] on failure.""" frames: list[dict] = [] try: with open(path, "r") as f: for line in f: line = line.strip() if not line: continue try: data = json.loads(line) except json.JSONDecodeError as exc: log.warning("Skipping bad line in %s: %s", path.name, exc) continue if "q" in data: frames.append(data) except OSError: log.exception("Failed to read motion file %s", path) if not frames: log.warning("No usable frames in %s", path.name) return frames def _ramp(self, adapter: "_ArmAdapter", from_q: list[float], to_q: list[float], body_lock_q: list[float], steps: int, label: str) -> bool: """Smoothly interpolate from `from_q` to `to_q` over `steps` frames. Returns True on success, False if cancelled or send failed.""" log.info("MacroPlayer %s (%d steps)", label, steps) interval = 1.0 / REPLAY_HZ for step in range(steps): if self._stop_event.is_set(): return False t = (step + 1) / steps interp = _lerp_q(from_q, to_q, t) try: adapter.send_frame(interp, body_lock_q) except Exception: log.exception("%s send_frame failed", label) return False time.sleep(interval) return True def _stream_frames(self, adapter: "_ArmAdapter", frames: list[dict], body_lock_q: list[float], interval: float) -> list[float] | None: """Stream the recorded frames with watchdog. Returns the last successfully sent q.""" t0 = time.monotonic() last_q: list[float] | None = None for frame in frames: if self._stop_event.is_set(): break age = adapter.state_age() if age > WATCHDOG_DISABLE_AFTER: log.error("MacroPlayer watchdog abort — state stale %.2fs", age) break target_t = frame.get("t", 0) elapsed = time.monotonic() - t0 sleep_time = target_t - elapsed if sleep_time > 0: time.sleep(sleep_time) try: adapter.send_frame(frame["q"], body_lock_q) last_q = frame["q"] except Exception: log.exception("send_frame failed mid-replay") return last_q def list_macros(self) -> list[dict[str, Any]]: """List available macro recordings (paired audio + motion).""" macros = [] for audio_path in sorted(AUDIO_RECORDINGS_DIR.glob("*.wav")): name = audio_path.stem motion_path = MOTION_RECORDINGS_DIR / f"{name}.jsonl" if motion_path.exists(): macros.append({ "name": name, "audio_path": str(audio_path), "motion_path": str(motion_path), "audio_size_kb": round(audio_path.stat().st_size / 1024, 1), "motion_size_kb": round(motion_path.stat().st_size / 1024, 1), }) return macros def status(self) -> dict[str, Any]: return {"playing": self._playing}