Spaces:
Running
Running
| """Tier 2 — Hardware integration tests for Marionette. | |
| These tests require a physical Reachy Mini robot connected and powered on. | |
| They exercise the full record/playback pipeline through the HTTP API with | |
| real motor control. | |
| All tests are decorated @pytest.mark.hardware and are skipped by default. | |
| Run with: | |
| cd marionette | |
| pytest tests/test_hardware.py -m hardware | |
| Or via the test runner: | |
| python tests/run_tests.py --hardware | |
| """ | |
| import json | |
| import threading | |
| import time | |
| import wave | |
| from pathlib import Path | |
| import pytest | |
| import uvicorn | |
| from marionette.main import create_app, COUNTDOWN_SECONDS, MOTION_SAMPLE_RATE | |
| # Skip all tests in this module unless -m hardware is specified | |
| pytestmark = pytest.mark.hardware | |
| HARDWARE_PORT = 18043 | |
| STARTUP_TIMEOUT = 30 # seconds to wait for robot startup animation | |
| POLL_INTERVAL = 0.25 | |
| # Module-level flag: set when the server gets stuck (e.g. audio hang). | |
| # Subsequent tests skip immediately instead of waiting 30s each. | |
| _server_stuck = False | |
| class _ServerThread: | |
| """Runs a uvicorn server in a background thread.""" | |
| def __init__(self, app, port: int): | |
| self.config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning") | |
| self.server = uvicorn.Server(self.config) | |
| self.thread = threading.Thread(target=self.server.run, daemon=True) | |
| def start(self): | |
| self.thread.start() | |
| for _ in range(50): | |
| if self.server.started: | |
| break | |
| time.sleep(0.1) | |
| def stop(self): | |
| self.server.should_exit = True | |
| self.thread.join(timeout=5) | |
| def _wait_for_mode(base_url: str, target_mode: str, timeout: float) -> dict: | |
| """Poll GET /api/state until mode matches target_mode or timeout.""" | |
| import httpx | |
| deadline = time.time() + timeout | |
| last_state = {} | |
| while time.time() < deadline: | |
| try: | |
| resp = httpx.get(f"{base_url}/api/state", timeout=5) | |
| last_state = resp.json() | |
| if last_state.get("mode") == target_mode: | |
| return last_state | |
| except Exception: | |
| pass | |
| time.sleep(POLL_INTERVAL) | |
| raise TimeoutError( | |
| f"Timed out waiting for mode={target_mode!r} " | |
| f"(last mode={last_state.get('mode')!r})" | |
| ) | |
| def _ensure_idle(base_url: str, timeout: float = 30) -> dict: | |
| """Force the server back to idle, or skip the test if unrecoverable. | |
| When the server is stuck (e.g. audio recording hung), this sends stop | |
| commands and waits. If the server cannot recover within *timeout* | |
| seconds, the current test is skipped (not failed) so that one stuck | |
| audio operation doesn't cascade-fail every subsequent test. | |
| """ | |
| global _server_stuck | |
| import httpx | |
| # If a previous test already detected the server is stuck, skip fast. | |
| if _server_stuck: | |
| pytest.skip("Server stuck from a previous test (likely audio hang)") | |
| try: | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| except Exception: | |
| _server_stuck = True | |
| pytest.skip("Cannot reach server") | |
| mode = state.get("mode") | |
| if mode == "idle": | |
| return state | |
| # Try to cancel whatever is running | |
| try: | |
| if mode in ("recording", "countdown", "queued"): | |
| httpx.post(f"{base_url}/api/record/stop", timeout=5) | |
| elif mode == "playing": | |
| httpx.post(f"{base_url}/api/play/stop", timeout=5) | |
| except Exception: | |
| pass | |
| try: | |
| return _wait_for_mode(base_url, "idle", timeout) | |
| except TimeoutError: | |
| _server_stuck = True | |
| pytest.skip( | |
| f"Server stuck in mode={mode!r} after stop — " | |
| f"likely audio/media hang on this platform" | |
| ) | |
| def _connect_robot(): | |
| """Connect to ReachyMini with a timeout. Returns (reachy, error_msg).""" | |
| result = [None, None] # [reachy, error] | |
| def _connect(): | |
| try: | |
| from reachy_mini import ReachyMini | |
| result[0] = ReachyMini() | |
| except Exception as exc: | |
| result[1] = str(exc) | |
| t = threading.Thread(target=_connect, daemon=True) | |
| t.start() | |
| t.join(timeout=15) | |
| if t.is_alive(): | |
| return None, "ReachyMini() connection timed out after 15s" | |
| if result[1]: | |
| return None, result[1] | |
| return result[0], None | |
| def hardware_server(tmp_path_factory): | |
| """Marionette server connected to a real robot. | |
| Starts the run() loop with a real ReachyMini and a uvicorn web server. | |
| Waits for the startup animation to complete (mode -> idle) before | |
| yielding the base URL and Marionette instance. | |
| """ | |
| reachy, err = _connect_robot() | |
| if reachy is None: | |
| pytest.skip(f"Cannot connect to robot: {err}") | |
| tmp = tmp_path_factory.mktemp("hardware") | |
| app, marionette = create_app( | |
| registry_path=tmp / "registry.json", | |
| dataset_root=tmp / "datasets", | |
| ) | |
| stop_event = threading.Event() | |
| # Start the run() loop — processes recordings/playbacks using the real robot | |
| run_thread = threading.Thread( | |
| target=marionette.run, args=(reachy, stop_event), daemon=True | |
| ) | |
| run_thread.start() | |
| # Start the web server | |
| srv = _ServerThread(app, HARDWARE_PORT) | |
| srv.start() | |
| base_url = f"http://127.0.0.1:{HARDWARE_PORT}" | |
| # Wait for startup animation to finish | |
| try: | |
| _wait_for_mode(base_url, "idle", STARTUP_TIMEOUT) | |
| except TimeoutError: | |
| stop_event.set() | |
| srv.stop() | |
| pytest.skip("Robot startup animation did not finish in time") | |
| yield base_url, marionette, reachy | |
| stop_event.set() | |
| srv.stop() | |
| run_thread.join(timeout=5) | |
| def base_url(hardware_server): | |
| return hardware_server[0] | |
| def hw_marionette(hardware_server): | |
| return hardware_server[1] | |
| def hw_reachy(hardware_server): | |
| """The ReachyMini instance used by the Marionette server.""" | |
| return hardware_server[2] | |
| # ──────── Tests ──────────────────────────────────────────────────────── | |
| # | |
| # Test ordering: silent operations first, audio operations last. | |
| # If audio hangs (known Ubuntu/mic issue), silent tests still pass and | |
| # audio tests are cleanly skipped via _ensure_idle. | |
| # ────────────────────────────────────────────────────────────────────── | |
| class TestHardwareStartup: | |
| def test_startup_reaches_idle(self, base_url: str): | |
| """After fixture setup, verify mode is 'idle'.""" | |
| import httpx | |
| resp = httpx.get(f"{base_url}/api/state", timeout=5) | |
| assert resp.status_code == 200 | |
| assert resp.json()["mode"] == "idle" | |
| class TestHardwareRecording: | |
| def test_record_captures_motion(self, base_url: str): | |
| """Record 2s silent, verify move JSON has ~200 frames at ~100Hz.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={"duration": 2.0, "record_audio": False, "label": "hw-silent"}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| move_id = resp.json()["move_id"] | |
| _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + 2 + 10) | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| move = next((m for m in state["moves"] if m["id"] == move_id), None) | |
| assert move is not None, f"Move {move_id} not found in moves list" | |
| assert move["duration"] > 1.5, f"Duration too short: {move['duration']}" | |
| assert move["has_audio"] is False | |
| class TestHardwarePlayback: | |
| def test_playback_silent_completes(self, base_url: str): | |
| """Play back a silent move, verify mode returns to idle.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| move = next((m for m in state["moves"] if not m["has_audio"]), None) | |
| if move is None: | |
| pytest.skip("No silent move available for playback test") | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move["id"]}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| _wait_for_mode(base_url, "idle", timeout=move["duration"] + 15) | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| assert state["mode"] == "idle" | |
| def test_record_and_delete(self, base_url: str): | |
| """Record a move, then delete it and verify file is removed.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={"duration": 1.5, "record_audio": False, "label": "hw-delete-me"}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| move_id = resp.json()["move_id"] | |
| _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + 1.5 + 10) | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| assert any(m["id"] == move_id for m in state["moves"]) | |
| resp = httpx.delete( | |
| f"{base_url}/api/moves/{move_id}", | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| assert not any(m["id"] == move_id for m in state["moves"]) | |
| class TestFullPipeline: | |
| """End-to-end pipeline tests: record → verify files → replay → delete. | |
| These tests exercise the full lifecycle and check that output files | |
| have the expected structure, durations, and frame counts. | |
| """ | |
| def test_recording_produces_correct_json(self, base_url: str, hw_marionette): | |
| """Record 3s silent, verify JSON has timestamps, frames at ~100Hz.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| duration = 3.0 | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={"duration": duration, "record_audio": False, "label": "pipeline-json"}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| move_id = resp.json()["move_id"] | |
| _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + duration + 10) | |
| # Read the actual JSON file from disk | |
| json_path = hw_marionette._dataset_dir / f"{move_id}.json" | |
| assert json_path.exists(), f"JSON file not found: {json_path}" | |
| data = json.loads(json_path.read_text()) | |
| timestamps = data["time"] | |
| frames = data["set_target_data"] | |
| # Frame count should be close to duration * 100Hz | |
| expected_frames = int(duration * MOTION_SAMPLE_RATE) | |
| assert len(frames) > expected_frames * 0.8, ( | |
| f"Too few frames: {len(frames)} (expected ~{expected_frames})" | |
| ) | |
| assert len(timestamps) == len(frames) | |
| # Timestamps should span close to the requested duration | |
| actual_duration = timestamps[-1] - timestamps[0] | |
| assert actual_duration > duration * 0.8, ( | |
| f"Recorded duration too short: {actual_duration:.2f}s (expected ~{duration}s)" | |
| ) | |
| # Each frame must have head pose and antennas | |
| for frame in frames[:3]: # check first few | |
| assert "head" in frame | |
| assert "antennas" in frame | |
| # Cleanup | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| def test_record_replay_full_lifecycle(self, base_url: str, hw_marionette): | |
| """Record → verify → replay → verify playback completes → delete.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| duration = 2.0 | |
| # Step 1: Record | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={"duration": duration, "record_audio": False, "label": "lifecycle"}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| move_id = resp.json()["move_id"] | |
| state = _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + duration + 10) | |
| assert any(m["id"] == move_id for m in state["moves"]), "Move not in list after recording" | |
| # Step 2: Replay | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move_id}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| state = _wait_for_mode(base_url, "idle", timeout=duration + 15) | |
| assert state["mode"] == "idle" | |
| # Step 3: Delete and verify cleanup | |
| json_path = hw_marionette._dataset_dir / f"{move_id}.json" | |
| assert json_path.exists() | |
| resp = httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| assert resp.status_code == 200 | |
| assert not json_path.exists(), "JSON file should be deleted" | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| assert not any(m["id"] == move_id for m in state["moves"]) | |
| def test_stop_cancels_queued_recording(self, base_url: str): | |
| """Submit a recording, immediately stop it — verify cancel works.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={"duration": 10.0, "record_audio": False, "label": "cancel-test"}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| # Immediately try to stop (may be queued or in countdown) | |
| time.sleep(0.1) | |
| resp = httpx.post(f"{base_url}/api/record/stop", timeout=5) | |
| assert resp.status_code == 200 | |
| data = resp.json() | |
| assert data["stopped"] is True | |
| # Should return to idle | |
| _wait_for_mode(base_url, "idle", timeout=10) | |
| def test_recording_transitions_through_phases(self, base_url: str): | |
| """Verify the recording goes through queued → countdown → recording → idle.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| duration = 3.0 | |
| observed_modes = set() | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={"duration": duration, "record_audio": False, "label": "phases"}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| # Poll rapidly to observe phase transitions | |
| deadline = time.time() + COUNTDOWN_SECONDS + duration + 10 | |
| while time.time() < deadline: | |
| try: | |
| state = httpx.get(f"{base_url}/api/state", timeout=2).json() | |
| observed_modes.add(state["mode"]) | |
| # When in recording mode, verify timing fields are present | |
| if state["mode"] == "recording": | |
| assert state["phase_start_at"] is not None, "recording phase_start_at is null" | |
| assert state["phase_end_at"] is not None, "recording phase_end_at is null" | |
| # When in countdown, verify timing fields | |
| if state["mode"] == "countdown": | |
| assert state["phase_start_at"] is not None, "countdown phase_start_at is null" | |
| assert state["phase_end_at"] is not None, "countdown phase_end_at is null" | |
| if state["mode"] == "idle" and "phases" in (state.get("message") or ""): | |
| break # Recording completed | |
| except Exception: | |
| pass | |
| time.sleep(0.15) | |
| # We should have seen at least countdown and recording phases | |
| assert "countdown" in observed_modes, ( | |
| f"Never saw countdown mode. Observed: {observed_modes}" | |
| ) | |
| assert "recording" in observed_modes, ( | |
| f"Never saw recording mode. Observed: {observed_modes}" | |
| ) | |
| assert "idle" in observed_modes, ( | |
| f"Never returned to idle. Observed: {observed_modes}" | |
| ) | |
| # Cleanup | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| move = next((m for m in state["moves"] if "phases" in m.get("label", "")), None) | |
| if move: | |
| httpx.delete(f"{base_url}/api/moves/{move['id']}", timeout=5) | |
| def _create_synthetic_move(hw_marionette, label, duration, trajectory_fn): | |
| """Create a synthetic recording and inject it into Marionette's dataset. | |
| Args: | |
| hw_marionette: The Marionette instance (from fixture). | |
| label: Label/move_id for the recording. | |
| duration: Duration in seconds. | |
| trajectory_fn: Callable(t) -> (roll, pitch, yaw) in radians. | |
| Called at 100Hz for the full duration. | |
| Returns: | |
| move_id (str) — the ID of the injected move. | |
| """ | |
| import numpy as np | |
| from scipy.spatial.transform import Rotation as R | |
| dt = 1.0 / MOTION_SAMPLE_RATE | |
| n = int(duration * MOTION_SAMPLE_RATE) | |
| timestamps = [] | |
| frames = [] | |
| for i in range(n): | |
| t = i * dt | |
| roll, pitch, yaw = trajectory_fn(t) | |
| rot = R.from_euler("xyz", [roll, pitch, yaw], degrees=False).as_matrix() | |
| pose = np.eye(4) | |
| pose[:3, :3] = rot | |
| timestamps.append(t) | |
| frames.append({ | |
| "head": pose.tolist(), | |
| "antennas": [0.0, 0.0], | |
| "body_yaw": 0.0, | |
| "check_collision": False, | |
| }) | |
| move_id = label | |
| data = { | |
| "description": f"Synthetic reference: {label}", | |
| "time": timestamps, | |
| "set_target_data": frames, | |
| } | |
| json_path = hw_marionette._dataset_dir / f"{move_id}.json" | |
| json_path.write_text(json.dumps(data, indent=2), encoding="utf-8") | |
| # Tell Marionette to pick up the new file | |
| hw_marionette._refresh_recordings() | |
| return move_id | |
| def _observe_playback(base_url, hw_reachy, duration): | |
| """Observe head poses during playback at ~100Hz. | |
| Waits for playing mode, records poses until idle, returns | |
| (observed_times, observed_frames). | |
| """ | |
| import httpx | |
| import numpy as np | |
| observed_times = [] | |
| observed_frames = [] | |
| playback_started = False | |
| t_start = time.time() | |
| t0 = None | |
| last_state_check = 0 | |
| mode = "unknown" | |
| while time.time() - t_start < duration + 30: | |
| now = time.time() | |
| # Check API state every 0.5s (not every iteration — too slow) | |
| if now - last_state_check > 0.5: | |
| try: | |
| state = httpx.get(f"{base_url}/api/state", timeout=2).json() | |
| mode = state["mode"] | |
| except Exception: | |
| pass | |
| last_state_check = now | |
| if mode == "playing": | |
| if not playback_started: | |
| playback_started = True | |
| t0 = now | |
| pose = hw_reachy.get_current_head_pose() | |
| elapsed = now - t0 | |
| observed_times.append(elapsed) | |
| observed_frames.append({ | |
| "head": np.asarray(pose, dtype=float).tolist(), | |
| "antennas": [0.0, 0.0], | |
| "body_yaw": 0.0, | |
| }) | |
| elif playback_started and mode == "idle": | |
| break | |
| time.sleep(0.01) | |
| return observed_times, observed_frames | |
| def _write_silent_wav(path: Path, duration: float, sample_rate: int = 48000) -> None: | |
| """Write a silent WAV file of the given duration.""" | |
| import struct | |
| n_frames = int(duration * sample_rate) | |
| with wave.open(str(path), "wb") as wf: | |
| wf.setnchannels(1) | |
| wf.setsampwidth(2) | |
| wf.setframerate(sample_rate) | |
| wf.writeframes(struct.pack(f"<{n_frames}h", *([0] * n_frames))) | |
| class TestMotionAccuracy: | |
| """Play back synthetic reference recordings and compare observed poses. | |
| Creates known-trajectory JSON files, injects them into Marionette's | |
| dataset, plays them via the API, and observes actual robot poses | |
| during playback using get_current_head_pose(). | |
| """ | |
| def test_playback_reproduces_yaw_oscillation( | |
| self, base_url: str, hw_marionette, hw_reachy, | |
| ): | |
| """Inject sine-wave yaw reference, play back, observe, compare.""" | |
| import httpx | |
| import numpy as np | |
| from scipy.spatial.transform import Rotation as R | |
| from pose_utils import compare_trajectories, frames_to_poses | |
| duration = 3.0 | |
| freq, amplitude = 0.5, 0.4 | |
| _ensure_idle(base_url) | |
| # Create synthetic reference with yaw oscillation | |
| move_id = _create_synthetic_move( | |
| hw_marionette, | |
| label="synth-yaw-osc", | |
| duration=duration, | |
| trajectory_fn=lambda t: (0.0, 0.0, amplitude * np.sin(2 * np.pi * freq * t)), | |
| ) | |
| # Load reference for comparison | |
| ref_data = json.loads( | |
| (hw_marionette._dataset_dir / f"{move_id}.json").read_text() | |
| ) | |
| ref_times = ref_data["time"] | |
| ref_frames = ref_data["set_target_data"] | |
| # Play it back | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move_id}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| # Observe poses during playback | |
| observed_times, observed_frames = _observe_playback( | |
| base_url, hw_reachy, duration, | |
| ) | |
| assert len(observed_frames) > 50, ( | |
| f"Too few observed frames: {len(observed_frames)}" | |
| ) | |
| # Compare reference to observed | |
| metrics = compare_trajectories( | |
| ref_times, ref_frames, observed_times, observed_frames, | |
| ) | |
| print(f"\nYaw oscillation playback ({len(observed_frames)} frames):") | |
| print(metrics.summary()) | |
| assert metrics.magic_mean < 50, ( | |
| f"Mean magic distance too high: {metrics.magic_mean:.1f}\n" | |
| f"{metrics.summary()}" | |
| ) | |
| # Verify the yaw actually varied — robot moved | |
| rec_poses = frames_to_poses(observed_frames) | |
| yaws = [R.from_matrix(p[:3, :3]).as_euler("xyz")[2] for p in rec_poses] | |
| yaw_range = max(yaws) - min(yaws) | |
| assert yaw_range > 0.3, ( | |
| f"Yaw range too small: {yaw_range:.2f} rad — robot may not have moved" | |
| ) | |
| print(f" Yaw range: {np.degrees(yaw_range):.1f} deg") | |
| # Cleanup | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| def test_playback_reproduces_pitch_roll( | |
| self, base_url: str, hw_marionette, hw_reachy, | |
| ): | |
| """Inject combined pitch+roll reference, play back, observe, compare.""" | |
| import httpx | |
| import numpy as np | |
| from pose_utils import compare_trajectories | |
| duration = 3.0 | |
| _ensure_idle(base_url) | |
| move_id = _create_synthetic_move( | |
| hw_marionette, | |
| label="synth-pitch-roll", | |
| duration=duration, | |
| trajectory_fn=lambda t: ( | |
| 0.15 * np.sin(2 * np.pi * 0.4 * t), # roll | |
| 0.2 * np.sin(2 * np.pi * 0.3 * t), # pitch | |
| 0.0, # yaw | |
| ), | |
| ) | |
| ref_data = json.loads( | |
| (hw_marionette._dataset_dir / f"{move_id}.json").read_text() | |
| ) | |
| ref_times = ref_data["time"] | |
| ref_frames = ref_data["set_target_data"] | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move_id}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| observed_times, observed_frames = _observe_playback( | |
| base_url, hw_reachy, duration, | |
| ) | |
| assert len(observed_frames) > 50, ( | |
| f"Too few observed frames: {len(observed_frames)}" | |
| ) | |
| metrics = compare_trajectories( | |
| ref_times, ref_frames, observed_times, observed_frames, | |
| ) | |
| print(f"\nPitch+roll playback ({len(observed_frames)} frames):") | |
| print(metrics.summary()) | |
| assert metrics.magic_mean < 50, ( | |
| f"Mean magic distance too high: {metrics.magic_mean:.1f}\n" | |
| f"{metrics.summary()}" | |
| ) | |
| # Cleanup | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| def test_playback_long_motion( | |
| self, base_url: str, hw_marionette, hw_reachy, | |
| ): | |
| """10s reference with slow oscillation — verify accuracy over time.""" | |
| import httpx | |
| import numpy as np | |
| from pose_utils import compare_trajectories | |
| duration = 10.0 | |
| _ensure_idle(base_url) | |
| move_id = _create_synthetic_move( | |
| hw_marionette, | |
| label="synth-long-yaw", | |
| duration=duration, | |
| trajectory_fn=lambda t: (0.0, 0.0, 0.3 * np.sin(2 * np.pi * 0.2 * t)), | |
| ) | |
| ref_data = json.loads( | |
| (hw_marionette._dataset_dir / f"{move_id}.json").read_text() | |
| ) | |
| ref_times = ref_data["time"] | |
| ref_frames = ref_data["set_target_data"] | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move_id}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| observed_times, observed_frames = _observe_playback( | |
| base_url, hw_reachy, duration, | |
| ) | |
| assert len(observed_frames) > 200, ( | |
| f"Too few observed frames for 10s playback: {len(observed_frames)}" | |
| ) | |
| metrics = compare_trajectories( | |
| ref_times, ref_frames, observed_times, observed_frames, | |
| ) | |
| print(f"\nLong playback ({len(observed_frames)} frames, {duration}s):") | |
| print(metrics.summary()) | |
| assert metrics.magic_mean < 50, ( | |
| f"Mean magic distance too high: {metrics.magic_mean:.1f}\n" | |
| f"{metrics.summary()}" | |
| ) | |
| # Check first half vs second half — accuracy shouldn't degrade | |
| half = len(metrics.magic_distances) // 2 | |
| first_half_mean = float(np.mean(metrics.magic_distances[:half])) | |
| second_half_mean = float(np.mean(metrics.magic_distances[half:])) | |
| print(f" First half mean: {first_half_mean:.1f}") | |
| print(f" Second half mean: {second_half_mean:.1f}") | |
| # Second half shouldn't be more than 2x worse than first half | |
| assert second_half_mean < first_half_mean * 2 + 10, ( | |
| f"Accuracy degraded over time: first={first_half_mean:.1f}, " | |
| f"second={second_half_mean:.1f}" | |
| ) | |
| # Cleanup | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| class TestAudioMotionSync: | |
| """Verify that audio and motion play back together correctly. | |
| Creates a synthetic move with a matching WAV file, plays it back, | |
| measures wall-clock duration and observed joint positions, and | |
| verifies both timing and trajectory accuracy. | |
| """ | |
| def test_audio_motion_sync_playback( | |
| self, base_url: str, hw_marionette, hw_reachy, | |
| ): | |
| """Inject synthetic move + WAV, play back, verify timing and accuracy.""" | |
| import httpx | |
| import numpy as np | |
| from pose_utils import compare_trajectories | |
| duration = 3.0 | |
| _ensure_idle(base_url) | |
| # Step 1: Create synthetic move — gentle yaw oscillation | |
| move_id = _create_synthetic_move( | |
| hw_marionette, | |
| label="synth-audio-sync", | |
| duration=duration, | |
| trajectory_fn=lambda t: (0.0, 0.0, 0.3 * np.sin(2 * np.pi * 0.5 * t)), | |
| ) | |
| # Step 2: Write matching silent WAV alongside the JSON | |
| wav_path = hw_marionette._dataset_dir / f"{move_id}.wav" | |
| _write_silent_wav(wav_path, duration=duration, sample_rate=48000) | |
| # Step 3: Refresh and verify has_audio | |
| hw_marionette._refresh_recordings() | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| move = next((m for m in state["moves"] if m["id"] == move_id), None) | |
| assert move is not None, f"Move {move_id} not in state after refresh" | |
| assert move["has_audio"] is True, "Move should have audio (WAV exists)" | |
| # Step 4: Load reference for comparison | |
| ref_data = json.loads( | |
| (hw_marionette._dataset_dir / f"{move_id}.json").read_text() | |
| ) | |
| ref_times = ref_data["time"] | |
| ref_frames = ref_data["set_target_data"] | |
| # Step 5: Play back and measure wall-clock + poses | |
| t0 = time.time() | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move_id}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| observed_times, observed_frames = _observe_playback( | |
| base_url, hw_reachy, duration, | |
| ) | |
| _wait_for_mode(base_url, "idle", timeout=duration + 15) | |
| wall_clock = time.time() - t0 | |
| # Step 6: Timing assertions — audio shouldn't cut short or hang | |
| assert wall_clock > duration * 0.8, ( | |
| f"Playback too fast: {wall_clock:.2f}s — audio likely cut short " | |
| f"(expected ≥{duration * 0.8:.1f}s)" | |
| ) | |
| assert wall_clock < duration + 10, ( | |
| f"Playback too slow: {wall_clock:.2f}s — possibly stuck " | |
| f"(expected <{duration + 10:.1f}s)" | |
| ) | |
| # Step 7: Verify enough frames observed (robot actually moved) | |
| assert len(observed_frames) > 10, ( | |
| f"Too few observed frames: {len(observed_frames)} — " | |
| f"robot may not have moved" | |
| ) | |
| # Step 8: Compare trajectories | |
| metrics = compare_trajectories( | |
| ref_times, ref_frames, observed_times, observed_frames, | |
| ) | |
| print(f"\nAudio-motion sync ({len(observed_frames)} frames, " | |
| f"wall={wall_clock:.2f}s):") | |
| print(metrics.summary()) | |
| assert metrics.magic_mean < 50, ( | |
| f"Mean magic distance too high: {metrics.magic_mean:.1f}\n" | |
| f"{metrics.summary()}" | |
| ) | |
| # Cleanup | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| def test_audio_does_not_truncate_motion( | |
| self, base_url: str, hw_marionette, | |
| ): | |
| """Verify that audio playback doesn't end before motion completes. | |
| Creates a 5s move + 5s WAV, plays back, measures wall-clock to | |
| ensure the full duration plays out. | |
| """ | |
| import httpx | |
| import numpy as np | |
| duration = 5.0 | |
| _ensure_idle(base_url) | |
| move_id = _create_synthetic_move( | |
| hw_marionette, | |
| label="synth-audio-notrim", | |
| duration=duration, | |
| trajectory_fn=lambda t: (0.0, 0.0, 0.2 * np.sin(2 * np.pi * 0.3 * t)), | |
| ) | |
| wav_path = hw_marionette._dataset_dir / f"{move_id}.wav" | |
| _write_silent_wav(wav_path, duration=duration, sample_rate=48000) | |
| hw_marionette._refresh_recordings() | |
| # Play and measure | |
| _ensure_idle(base_url) | |
| t0 = time.time() | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move_id}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| _wait_for_mode(base_url, "idle", timeout=duration + 20) | |
| wall_clock = time.time() - t0 | |
| print(f"\nAudio-motion no-trim: wall={wall_clock:.2f}s for {duration}s move+audio") | |
| # Playback should last at least 80% of the duration | |
| assert wall_clock > duration * 0.8, ( | |
| f"Playback cut short: {wall_clock:.2f}s (expected ≥{duration * 0.8:.1f}s)" | |
| ) | |
| assert wall_clock < duration + 15, ( | |
| f"Playback hung: {wall_clock:.2f}s (expected <{duration + 15:.1f}s)" | |
| ) | |
| # Cleanup | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| class TestMultiDuration: | |
| """Test recording and playback across different durations. | |
| Verifies frame counts, timing, and data integrity for short, medium, | |
| and long recordings. | |
| """ | |
| def test_record_duration(self, base_url: str, hw_marionette, duration: float, label: str): | |
| """Record at various durations, verify frame count and timing.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={"duration": duration, "record_audio": False, "label": label}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| move_id = resp.json()["move_id"] | |
| _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + duration + 15) | |
| # Verify the recording | |
| json_path = hw_marionette._dataset_dir / f"{move_id}.json" | |
| assert json_path.exists() | |
| data = json.loads(json_path.read_text()) | |
| timestamps = data["time"] | |
| frames = data["set_target_data"] | |
| expected_frames = int(duration * MOTION_SAMPLE_RATE) | |
| # Frame count within 20% of expected | |
| assert len(frames) > expected_frames * 0.8, ( | |
| f"{label}: too few frames {len(frames)} (expected ~{expected_frames})" | |
| ) | |
| assert len(frames) < expected_frames * 1.2, ( | |
| f"{label}: too many frames {len(frames)} (expected ~{expected_frames})" | |
| ) | |
| # Duration within 20% of expected | |
| actual_duration = timestamps[-1] - timestamps[0] | |
| assert actual_duration > duration * 0.8, ( | |
| f"{label}: duration too short {actual_duration:.2f}s (expected ~{duration}s)" | |
| ) | |
| # Frame rate should be close to 100Hz | |
| actual_fps = len(frames) / actual_duration if actual_duration > 0 else 0 | |
| assert actual_fps > 80, f"{label}: frame rate too low {actual_fps:.1f}Hz (expected ~100Hz)" | |
| assert actual_fps < 120, f"{label}: frame rate too high {actual_fps:.1f}Hz (expected ~100Hz)" | |
| print(f"\n{label}: {len(frames)} frames in {actual_duration:.2f}s = {actual_fps:.1f}Hz") | |
| # Cleanup | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| def test_playback_duration(self, base_url: str, hw_marionette, duration: float, label: str): | |
| """Record then play back at various durations, verify timing.""" | |
| import httpx | |
| # First record a move | |
| _ensure_idle(base_url) | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={"duration": duration, "record_audio": False, "label": label}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| move_id = resp.json()["move_id"] | |
| _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + duration + 15) | |
| # Now play it back and measure how long it takes | |
| _ensure_idle(base_url) | |
| t0 = time.time() | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move_id}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| _wait_for_mode(base_url, "idle", timeout=duration + 30) | |
| playback_time = time.time() - t0 | |
| # Playback time should be close to the recording duration | |
| # (plus some overhead for goto-start-pose) | |
| assert playback_time > duration * 0.8, ( | |
| f"{label}: playback too fast {playback_time:.2f}s (expected ~{duration}s)" | |
| ) | |
| assert playback_time < duration + 10, ( | |
| f"{label}: playback too slow {playback_time:.2f}s (expected ~{duration}s + overhead)" | |
| ) | |
| print(f"\n{label}: playback took {playback_time:.2f}s (recording was {duration}s)") | |
| # Cleanup | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| class TestPerformance: | |
| """Timing and performance benchmarks.""" | |
| def test_recording_start_latency(self, base_url: str): | |
| """Measure time from POST /api/record to countdown mode.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| t0 = time.time() | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={"duration": 2.0, "record_audio": False, "label": "latency-test"}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| # Poll until we see countdown | |
| while time.time() - t0 < 5: | |
| state = httpx.get(f"{base_url}/api/state", timeout=2).json() | |
| if state["mode"] in ("countdown", "recording"): | |
| latency = time.time() - t0 | |
| print(f"\nRecording start latency: {latency*1000:.0f}ms") | |
| assert latency < 5.0, f"Recording start too slow: {latency:.2f}s" | |
| break | |
| time.sleep(0.05) | |
| # Let it finish | |
| _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + 2 + 10) | |
| # Cleanup | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| for m in state["moves"]: | |
| if "latency" in m.get("label", ""): | |
| httpx.delete(f"{base_url}/api/moves/{m['id']}", timeout=5) | |
| def test_playback_start_latency(self, base_url: str): | |
| """Measure time from POST /api/play to playing mode.""" | |
| import httpx | |
| # Need a move to play — record a quick one | |
| _ensure_idle(base_url) | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={"duration": 1.5, "record_audio": False, "label": "play-latency-src"}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| move_id = resp.json()["move_id"] | |
| _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + 1.5 + 10) | |
| # Now measure playback start | |
| _ensure_idle(base_url) | |
| t0 = time.time() | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move_id}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| while time.time() - t0 < 10: | |
| state = httpx.get(f"{base_url}/api/state", timeout=2).json() | |
| if state["mode"] == "playing": | |
| latency = time.time() - t0 | |
| print(f"\nPlayback start latency: {latency*1000:.0f}ms") | |
| assert latency < 5.0, f"Playback start too slow: {latency:.2f}s" | |
| break | |
| time.sleep(0.05) | |
| _wait_for_mode(base_url, "idle", timeout=15) | |
| # Cleanup | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| def test_pose_read_rate(self, base_url: str): | |
| """Measure how fast we can read poses from the SDK.""" | |
| from reachy_mini import ReachyMini | |
| _ensure_idle(base_url) | |
| with ReachyMini(media_backend="no_media") as reachy: | |
| n_reads = 500 | |
| t0 = time.time() | |
| for _ in range(n_reads): | |
| reachy.get_current_head_pose() | |
| elapsed = time.time() - t0 | |
| rate = n_reads / elapsed | |
| print(f"\nPose read rate: {rate:.0f} reads/s ({elapsed*1000/n_reads:.1f}ms per read)") | |
| assert rate > 50, f"Pose read rate too low: {rate:.0f}/s (need >50 for 100Hz recording)" | |
| class TestExistingRecordingPlayback: | |
| """Play back existing recordings from the dataset.""" | |
| def test_playback_real_audio_recording(self, base_url: str): | |
| """Find a move with audio and play it back.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| move = next((m for m in state["moves"] if m["has_audio"]), None) | |
| if move is None: | |
| pytest.skip("No audio moves available for playback test") | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move["id"]}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| _wait_for_mode(base_url, "idle", timeout=move["duration"] + 15) | |
| def test_playback_real_silent_recording(self, base_url: str): | |
| """Find a silent move and play it back.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| move = next((m for m in state["moves"] if not m["has_audio"]), None) | |
| if move is None: | |
| pytest.skip("No silent moves available for playback test") | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move["id"]}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| _wait_for_mode(base_url, "idle", timeout=move["duration"] + 15) | |
| class TestRecordingRoundTrip: | |
| """Record, read JSON, play back, and verify timing and fidelity.""" | |
| def test_record_then_playback_fidelity( | |
| self, base_url: str, hw_marionette, hw_reachy, | |
| ): | |
| """Record 3s, play back, observe poses, compare trajectories.""" | |
| import httpx | |
| from pose_utils import compare_trajectories | |
| duration = 3.0 | |
| _ensure_idle(base_url) | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={"duration": duration, "record_audio": False, "label": "roundtrip-fidelity"}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| move_id = resp.json()["move_id"] | |
| _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + duration + 10) | |
| # Read the recorded JSON | |
| json_path = hw_marionette._dataset_dir / f"{move_id}.json" | |
| assert json_path.exists() | |
| data = json.loads(json_path.read_text()) | |
| ref_times = data["time"] | |
| ref_frames = data["set_target_data"] | |
| # Play back and observe | |
| _ensure_idle(base_url) | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move_id}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| observed_times, observed_frames = _observe_playback( | |
| base_url, hw_reachy, duration, | |
| ) | |
| assert len(observed_frames) > 50, ( | |
| f"Too few observed frames: {len(observed_frames)}" | |
| ) | |
| metrics = compare_trajectories( | |
| ref_times, ref_frames, observed_times, observed_frames, | |
| ) | |
| print(f"\nRound-trip fidelity ({len(observed_frames)} frames):") | |
| print(metrics.summary()) | |
| assert metrics.magic_mean < 50, ( | |
| f"Mean magic distance too high: {metrics.magic_mean:.1f}\n" | |
| f"{metrics.summary()}" | |
| ) | |
| # Cleanup | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| def test_record_playback_timing(self, base_url: str, hw_marionette): | |
| """Record 3s, play back, verify wall-clock duration within 20%.""" | |
| import httpx | |
| duration = 3.0 | |
| _ensure_idle(base_url) | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={"duration": duration, "record_audio": False, "label": "roundtrip-timing"}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| move_id = resp.json()["move_id"] | |
| _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + duration + 10) | |
| # Play back and measure | |
| _ensure_idle(base_url) | |
| t0 = time.time() | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move_id}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| _wait_for_mode(base_url, "idle", timeout=duration + 30) | |
| playback_time = time.time() - t0 | |
| # Should be within 20% of the recording duration (plus overhead) | |
| assert playback_time > duration * 0.8, ( | |
| f"Playback too fast: {playback_time:.2f}s (expected ~{duration}s)" | |
| ) | |
| assert playback_time < duration * 1.2 + 10, ( | |
| f"Playback too slow: {playback_time:.2f}s (expected ~{duration}s + overhead)" | |
| ) | |
| print(f"\nRound-trip timing: playback took {playback_time:.2f}s for {duration}s recording") | |
| # Cleanup | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| class TestAntennaAndBodyYaw: | |
| """Verify recording JSON contains antenna and body_yaw data.""" | |
| def test_recording_has_antenna_data(self, base_url: str, hw_marionette): | |
| """Every frame should have 'antennas' (2-element list).""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={"duration": 2.0, "record_audio": False, "label": "antenna-check"}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| move_id = resp.json()["move_id"] | |
| _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + 2 + 10) | |
| json_path = hw_marionette._dataset_dir / f"{move_id}.json" | |
| data = json.loads(json_path.read_text()) | |
| for frame in data["set_target_data"]: | |
| assert "antennas" in frame, "Frame missing 'antennas' key" | |
| assert len(frame["antennas"]) == 2, f"Expected 2 antenna values, got {len(frame['antennas'])}" | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| def test_recording_has_body_yaw(self, base_url: str, hw_marionette): | |
| """Every frame should have 'body_yaw' (float).""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={"duration": 2.0, "record_audio": False, "label": "bodyyaw-check"}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| move_id = resp.json()["move_id"] | |
| _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + 2 + 10) | |
| json_path = hw_marionette._dataset_dir / f"{move_id}.json" | |
| data = json.loads(json_path.read_text()) | |
| for frame in data["set_target_data"]: | |
| assert "body_yaw" in frame, "Frame missing 'body_yaw' key" | |
| assert isinstance(frame["body_yaw"], (int, float)), f"body_yaw should be numeric, got {type(frame['body_yaw'])}" | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| class TestPlaybackAntennas: | |
| """Test synthetic moves with antenna data play back correctly.""" | |
| def test_synthetic_antenna_motion_completes( | |
| self, base_url: str, hw_marionette, hw_reachy, | |
| ): | |
| """Create synthetic move with antenna oscillation, play back.""" | |
| import httpx | |
| import numpy as np | |
| duration = 3.0 | |
| _ensure_idle(base_url) | |
| # Create synthetic move with antenna oscillation | |
| dt = 1.0 / MOTION_SAMPLE_RATE | |
| n = int(duration * MOTION_SAMPLE_RATE) | |
| timestamps = [i * dt for i in range(n)] | |
| frames = [] | |
| for i in range(n): | |
| t = i * dt | |
| antenna_val = 0.3 * np.sin(2 * np.pi * 0.5 * t) | |
| frames.append({ | |
| "head": np.eye(4).tolist(), | |
| "antennas": [antenna_val, -antenna_val], | |
| "body_yaw": 0.0, | |
| "check_collision": False, | |
| }) | |
| move_id = "synth-antenna-osc" | |
| data = { | |
| "description": "Synthetic antenna oscillation", | |
| "time": timestamps, | |
| "set_target_data": frames, | |
| } | |
| json_path = hw_marionette._dataset_dir / f"{move_id}.json" | |
| json_path.write_text(json.dumps(data), encoding="utf-8") | |
| hw_marionette._refresh_recordings() | |
| # Play back | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move_id}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| _wait_for_mode(base_url, "idle", timeout=duration + 15) | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| assert state["mode"] == "idle" | |
| # Cleanup | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| class TestStopDuringPlayback: | |
| """Stop playback mid-stream — the #1 user-initiated interruption.""" | |
| def test_stop_during_playback_returns_to_idle(self, base_url: str, hw_marionette): | |
| """Start playback, stop mid-way, verify robot returns to idle gracefully.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| # Record a 5s move so we have time to stop mid-playback | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={"duration": 5.0, "record_audio": False, "label": "stop-play-test"}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| move_id = resp.json()["move_id"] | |
| _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + 5 + 10) | |
| # Start playback | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move_id}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| # Wait until we're actually in playing mode | |
| _wait_for_mode(base_url, "playing", timeout=15) | |
| # Let it play for 1-2 seconds | |
| time.sleep(1.5) | |
| # Stop mid-playback | |
| resp = httpx.post(f"{base_url}/api/play/stop", timeout=5) | |
| assert resp.status_code == 200 | |
| # Should return to idle | |
| state = _wait_for_mode(base_url, "idle", timeout=10) | |
| assert state["mode"] == "idle" | |
| # Cleanup | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| def test_stop_during_goto_start_pose(self, base_url: str, hw_marionette): | |
| """Stop playback during the initial goto-start-pose transition.""" | |
| import httpx | |
| import numpy as np | |
| _ensure_idle(base_url) | |
| # Create synthetic move with head far from neutral — long goto transition | |
| move_id = _create_synthetic_move( | |
| hw_marionette, | |
| label="stop-goto-test", | |
| duration=5.0, | |
| trajectory_fn=lambda t: (0.0, 0.0, 0.4), # constant yaw offset | |
| ) | |
| # Start playback — goto_pose_scaled will take time | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move_id}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| # Stop immediately (likely still in goto phase) | |
| time.sleep(0.3) | |
| resp = httpx.post(f"{base_url}/api/play/stop", timeout=5) | |
| assert resp.status_code == 200 | |
| state = _wait_for_mode(base_url, "idle", timeout=15) | |
| assert state["mode"] == "idle" | |
| # Cleanup | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| class TestStopDuringRecording: | |
| """Stop recording mid-capture — verify partial data is saved correctly.""" | |
| def test_stop_mid_recording_saves_partial(self, base_url: str, hw_marionette): | |
| """Record 10s, stop after 2-3s, verify partial JSON is saved with valid data.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={"duration": 10.0, "record_audio": False, "label": "stop-partial"}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| move_id = resp.json()["move_id"] | |
| # Wait for actual recording mode (not countdown) | |
| _wait_for_mode(base_url, "recording", timeout=COUNTDOWN_SECONDS + 5) | |
| # Let it record for 2 seconds | |
| time.sleep(2.0) | |
| # Stop recording | |
| resp = httpx.post(f"{base_url}/api/record/stop", timeout=5) | |
| assert resp.status_code == 200 | |
| state = _wait_for_mode(base_url, "idle", timeout=10) | |
| assert state["mode"] == "idle" | |
| # Verify partial recording was saved with reasonable data | |
| json_path = hw_marionette._dataset_dir / f"{move_id}.json" | |
| assert json_path.exists(), "Partial recording should be saved" | |
| data = json.loads(json_path.read_text()) | |
| timestamps = data["time"] | |
| frames = data["set_target_data"] | |
| # Should have ~2s worth of data (not 10s) | |
| assert len(frames) > 100, f"Expected ~200 frames for 2s, got {len(frames)}" | |
| assert len(frames) < 500, f"Expected <5s of data, got {len(frames)} frames" | |
| assert len(timestamps) == len(frames) | |
| # Verify frames have valid structure | |
| for frame in frames[:5]: | |
| assert "head" in frame | |
| assert "antennas" in frame | |
| print(f"\nPartial recording: {len(frames)} frames in {timestamps[-1]:.2f}s") | |
| # Cleanup | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| class TestMultipleRecordPlayCycles: | |
| """Back-to-back record/play cycles — the real-world usage pattern. | |
| Users record several takes in a row, playing them back between takes. | |
| Motor state drift and resource leaks show up after multiple cycles. | |
| """ | |
| def test_five_record_play_cycles(self, base_url: str, hw_marionette): | |
| """Run 5 record→play cycles, verify each completes and robot stays healthy.""" | |
| import httpx | |
| move_ids = [] | |
| for i in range(5): | |
| _ensure_idle(base_url) | |
| # Record 1.5s | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={"duration": 1.5, "record_audio": False, "label": f"cycle-{i}"}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200, f"Cycle {i}: record failed" | |
| move_id = resp.json()["move_id"] | |
| move_ids.append(move_id) | |
| _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + 1.5 + 10) | |
| # Verify recording exists | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| assert any(m["id"] == move_id for m in state["moves"]), ( | |
| f"Cycle {i}: move {move_id} not in list" | |
| ) | |
| # Play it back | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move_id}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200, f"Cycle {i}: play failed" | |
| _wait_for_mode(base_url, "idle", timeout=1.5 + 15) | |
| print(f" Cycle {i+1}/5 complete") | |
| # Verify all 5 moves still exist after all cycles | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| for mid in move_ids: | |
| assert any(m["id"] == mid for m in state["moves"]), ( | |
| f"Move {mid} disappeared after cycles" | |
| ) | |
| # Cleanup | |
| for mid in move_ids: | |
| httpx.delete(f"{base_url}/api/moves/{mid}", timeout=5) | |
| class TestPlaybackWithCorruptFile: | |
| """Playback when the JSON file is missing or corrupt. | |
| Users may delete files from the dataset folder while the app is running. | |
| """ | |
| def test_playback_deleted_file_returns_error(self, base_url: str, hw_marionette): | |
| """Delete a move's JSON mid-session, try to play — should fail gracefully.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| # Record a move | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={"duration": 1.5, "record_audio": False, "label": "corrupt-test"}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| move_id = resp.json()["move_id"] | |
| _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + 1.5 + 10) | |
| # Delete the JSON file behind the app's back | |
| json_path = hw_marionette._dataset_dir / f"{move_id}.json" | |
| assert json_path.exists() | |
| json_path.unlink() | |
| # Refresh so the app picks up the deletion | |
| hw_marionette._refresh_recordings() | |
| # Try to play — should get 404 (move no longer exists) | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move_id}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code in (404, 409), ( | |
| f"Expected 404/409 for deleted move, got {resp.status_code}" | |
| ) | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| assert state["mode"] in ("idle", "error") | |
| def test_playback_truncated_json_recovers(self, base_url: str, hw_marionette): | |
| """Write a truncated JSON, try to play — should fail without crashing.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| # Create a corrupt JSON file | |
| move_id = "corrupt-truncated" | |
| json_path = hw_marionette._dataset_dir / f"{move_id}.json" | |
| json_path.write_text('{"time": [0.0, 0.01], "set_target_data": [{"head', encoding="utf-8") | |
| hw_marionette._refresh_recordings() | |
| # Try to play — should fail gracefully (corrupt JSON can't be loaded) | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move_id}, | |
| timeout=5, | |
| ) | |
| # The move may not parse during refresh (skipped) or fail during load | |
| if resp.status_code == 200: | |
| # If it was accepted, it should recover to idle/error | |
| time.sleep(3) | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| assert state["mode"] in ("idle", "error"), ( | |
| f"Server stuck after corrupt playback: mode={state['mode']}" | |
| ) | |
| else: | |
| # 404 is fine — refresh skipped the corrupt file | |
| assert resp.status_code in (404, 409, 500) | |
| _ensure_idle(base_url) | |
| # Cleanup | |
| if json_path.exists(): | |
| json_path.unlink() | |
| hw_marionette._refresh_recordings() | |
| class TestHardwarePlaybackWithAudio: | |
| """Playback with uploaded audio — validates Fix 1 (audio lifecycle). | |
| These tests upload a WAV file, record motion with it, then play back | |
| to verify that both audio and motion complete fully (not cut short). | |
| """ | |
| def test_playback_with_uploaded_audio_completes(self, base_url: str, hw_marionette): | |
| """Upload WAV, record with it, play back — verify full completion.""" | |
| import httpx | |
| import io | |
| import struct | |
| import wave as wave_mod | |
| _ensure_idle(base_url) | |
| # Create a 2s WAV file in memory | |
| sr, dur = 44100, 2.0 | |
| n_frames = int(dur * sr) | |
| buf = io.BytesIO() | |
| with wave_mod.open(buf, "wb") as wf: | |
| wf.setnchannels(1) | |
| wf.setsampwidth(2) | |
| wf.setframerate(sr) | |
| wf.writeframes(struct.pack(f"<{n_frames}h", *([0] * n_frames))) | |
| wav_bytes = buf.getvalue() | |
| # Upload the WAV | |
| upload_resp = httpx.post( | |
| f"{base_url}/api/upload-audio", | |
| files={"file": ("test-play.wav", wav_bytes, "audio/wav")}, | |
| timeout=10, | |
| ) | |
| assert upload_resp.status_code == 200 | |
| upload_id = upload_resp.json()["upload_id"] | |
| # Record motion with uploaded audio | |
| _ensure_idle(base_url) | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={ | |
| "duration": 2.0, | |
| "record_audio": False, | |
| "record_motion": True, | |
| "uploaded_audio_id": upload_id, | |
| "label": "hw-upload-play", | |
| }, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| move_id = resp.json()["move_id"] | |
| _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + 2 + 15) | |
| # Verify recording has audio | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| move = next((m for m in state["moves"] if m["id"] == move_id), None) | |
| assert move is not None | |
| assert move["has_audio"] is True | |
| # Play it back and measure duration | |
| _ensure_idle(base_url) | |
| t0 = time.time() | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move_id}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| _wait_for_mode(base_url, "idle", timeout=move["duration"] + 20) | |
| playback_time = time.time() - t0 | |
| # Playback should last approximately the move duration (not 1s!) | |
| assert playback_time > 1.5, ( | |
| f"Playback too short: {playback_time:.2f}s — audio/motion likely cut short" | |
| ) | |
| print(f"\nPlayback with uploaded audio: {playback_time:.2f}s") | |
| # Cleanup | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| def test_multiple_playback_cycles_with_audio(self, base_url: str, hw_marionette): | |
| """Play the same move 3 times — verify no resource leaks.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| move = next((m for m in state["moves"] if m["has_audio"]), None) | |
| if move is None: | |
| pytest.skip("No audio move available for cycle test") | |
| for i in range(3): | |
| _ensure_idle(base_url) | |
| t0 = time.time() | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move["id"]}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200, f"Cycle {i}: play failed" | |
| _wait_for_mode(base_url, "idle", timeout=move["duration"] + 20) | |
| elapsed = time.time() - t0 | |
| print(f" Cycle {i+1}/3: {elapsed:.2f}s") | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| assert state["mode"] == "idle", f"Cycle {i}: not idle after playback" | |
| class TestHardwareAudioOnlyRecording: | |
| """Audio-only recording (mic, no motion) on real hardware.""" | |
| def test_audio_only_recording_and_playback(self, base_url: str, hw_marionette): | |
| """Record audio-only, verify WAV created, play it back.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={ | |
| "duration": 2.0, | |
| "record_audio": True, | |
| "record_motion": False, | |
| "label": "hw-audio-only", | |
| }, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| move_id = resp.json()["move_id"] | |
| try: | |
| _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + 2 + 20) | |
| except TimeoutError: | |
| global _server_stuck | |
| _server_stuck = True | |
| pytest.skip("Audio-only recording timed out") | |
| # Verify it's marked as audio_only | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| move = next((m for m in state["moves"] if m["id"] == move_id), None) | |
| assert move is not None | |
| assert move["audio_only"] is True | |
| # Play it back (audio-only playback) | |
| _ensure_idle(base_url) | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move_id}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| _wait_for_mode(base_url, "idle", timeout=15) | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| assert state["mode"] == "idle" | |
| # Cleanup | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| def test_audio_only_appears_in_robot_audio_list(self, base_url: str, hw_marionette): | |
| """After audio-only recording, WAV should appear in robot-audio list.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={ | |
| "duration": 1.5, | |
| "record_audio": True, | |
| "record_motion": False, | |
| "label": "hw-ao-list", | |
| }, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| move_id = resp.json()["move_id"] | |
| try: | |
| _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + 1.5 + 20) | |
| except TimeoutError: | |
| global _server_stuck | |
| _server_stuck = True | |
| pytest.skip("Audio-only recording timed out") | |
| # Check robot-audio endpoint | |
| resp = httpx.get(f"{base_url}/api/robot-audio", timeout=5) | |
| assert resp.status_code == 200 | |
| names = [f["name"] for f in resp.json()["files"]] | |
| assert move_id in names, ( | |
| f"Expected {move_id} in robot-audio list, got: {names}" | |
| ) | |
| # Cleanup | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| class TestHardwareRobotFileSelection: | |
| """Test the robot file selection workflow on real hardware.""" | |
| def test_robot_audio_list_populated(self, base_url: str): | |
| """GET /api/robot-audio should return files after recordings exist.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| resp = httpx.get(f"{base_url}/api/robot-audio", timeout=5) | |
| assert resp.status_code == 200 | |
| data = resp.json() | |
| assert "files" in data | |
| # Files may or may not exist depending on test order | |
| for f in data["files"]: | |
| assert "name" in f | |
| assert "path" in f | |
| def test_select_robot_audio_sets_upload_id(self, base_url: str): | |
| """POST /api/robot-audio/select with a valid file returns upload_id.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| # Get a file from the list | |
| list_resp = httpx.get(f"{base_url}/api/robot-audio", timeout=5) | |
| files = list_resp.json().get("files", []) | |
| if not files: | |
| pytest.skip("No robot audio files available") | |
| resp = httpx.post( | |
| f"{base_url}/api/robot-audio/select", | |
| json={"path": files[0]["path"]}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| data = resp.json() | |
| assert "upload_id" in data | |
| assert data["upload_id"] # non-empty | |
| assert "filename" in data | |
| def test_select_robot_audio_and_record(self, base_url: str): | |
| """Select a robot audio file, then record motion with it.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| list_resp = httpx.get(f"{base_url}/api/robot-audio", timeout=5) | |
| files = list_resp.json().get("files", []) | |
| if not files: | |
| pytest.skip("No robot audio files available") | |
| # Select the first file | |
| select_resp = httpx.post( | |
| f"{base_url}/api/robot-audio/select", | |
| json={"path": files[0]["path"]}, | |
| timeout=5, | |
| ) | |
| assert select_resp.status_code == 200 | |
| upload_id = select_resp.json()["upload_id"] | |
| # Record with the selected audio | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={ | |
| "duration": 2.0, | |
| "record_audio": False, | |
| "record_motion": True, | |
| "uploaded_audio_id": upload_id, | |
| "label": "hw-robot-file", | |
| }, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| move_id = resp.json()["move_id"] | |
| _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + 2 + 15) | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| move = next((m for m in state["moves"] if m["id"] == move_id), None) | |
| assert move is not None | |
| assert move["has_audio"] is True | |
| # Cleanup | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| class TestHardwareStopResponsiveness: | |
| """Verify stop commands respond quickly during all phases.""" | |
| def test_stop_recording_during_countdown(self, base_url: str): | |
| """Stop during countdown — should return to idle within a few seconds.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={"duration": 10.0, "record_audio": False, "label": "stop-countdown"}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| # Wait for countdown to start | |
| time.sleep(0.3) | |
| t0 = time.time() | |
| resp = httpx.post(f"{base_url}/api/record/stop", timeout=5) | |
| assert resp.status_code == 200 | |
| state = _wait_for_mode(base_url, "idle", timeout=5) | |
| stop_time = time.time() - t0 | |
| assert state["mode"] == "idle" | |
| print(f"\nStop during countdown: {stop_time:.2f}s") | |
| def test_stop_recording_during_capture(self, base_url: str): | |
| """Stop during active recording — should return to idle within 5s.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={"duration": 10.0, "record_audio": False, "label": "stop-capture"}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| # Wait for recording phase | |
| _wait_for_mode(base_url, "recording", timeout=COUNTDOWN_SECONDS + 5) | |
| time.sleep(1.0) | |
| t0 = time.time() | |
| resp = httpx.post(f"{base_url}/api/record/stop", timeout=5) | |
| assert resp.status_code == 200 | |
| state = _wait_for_mode(base_url, "idle", timeout=5) | |
| stop_time = time.time() - t0 | |
| assert state["mode"] == "idle" | |
| assert stop_time < 5.0, f"Stop took too long: {stop_time:.2f}s" | |
| print(f"\nStop during capture: {stop_time:.2f}s") | |
| # Cleanup | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| for m in state["moves"]: | |
| if "stop-capture" in m.get("label", ""): | |
| httpx.delete(f"{base_url}/api/moves/{m['id']}", timeout=5) | |
| def test_stop_playback_responds_quickly(self, base_url: str, hw_marionette): | |
| """Stop playback mid-stream — should return to idle within 5s.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| # Record a 5s move | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={"duration": 5.0, "record_audio": False, "label": "stop-play-resp"}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| move_id = resp.json()["move_id"] | |
| _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + 5 + 10) | |
| # Play it | |
| _ensure_idle(base_url) | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move_id}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| _wait_for_mode(base_url, "playing", timeout=15) | |
| time.sleep(1.0) | |
| # Stop and measure | |
| t0 = time.time() | |
| resp = httpx.post(f"{base_url}/api/play/stop", timeout=5) | |
| assert resp.status_code == 200 | |
| state = _wait_for_mode(base_url, "idle", timeout=10) | |
| stop_time = time.time() - t0 | |
| assert state["mode"] == "idle" | |
| assert stop_time < 5.0, f"Stop took too long: {stop_time:.2f}s" | |
| print(f"\nStop during playback: {stop_time:.2f}s") | |
| # Cleanup | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| class TestHardwareAudio: | |
| """Audio recording/playback tests — run last. | |
| Audio recording may hang on some platforms (known Ubuntu mic bug). | |
| These tests are isolated at the end so a hang doesn't block the | |
| silent tests above. If the server gets stuck, _ensure_idle will | |
| skip remaining tests instead of cascading failures. | |
| """ | |
| def test_record_with_audio(self, base_url: str): | |
| """Record 2s with mic, verify .wav file created.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={"duration": 2.0, "record_audio": True, "label": "hw-audio"}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| move_id = resp.json()["move_id"] | |
| # Audio recording can take longer due to mic buffering | |
| try: | |
| _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + 2 + 20) | |
| except TimeoutError: | |
| global _server_stuck | |
| _server_stuck = True | |
| pytest.skip( | |
| "Audio recording timed out — likely mic/audio issue on this platform. " | |
| "Check that the default system mic is NOT the robot's mic." | |
| ) | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| move = next((m for m in state["moves"] if m["id"] == move_id), None) | |
| assert move is not None, f"Move {move_id} not found" | |
| assert move["has_audio"] is True | |
| def test_recording_with_audio_produces_wav(self, base_url: str, hw_marionette): | |
| """Record 3s with mic, verify WAV file duration matches recording.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| duration = 3.0 | |
| resp = httpx.post( | |
| f"{base_url}/api/record", | |
| json={"duration": duration, "record_audio": True, "label": "pipeline-audio"}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| move_id = resp.json()["move_id"] | |
| try: | |
| _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + duration + 20) | |
| except TimeoutError: | |
| global _server_stuck | |
| _server_stuck = True | |
| pytest.skip("Audio recording timed out — mic/audio issue on this platform") | |
| # Verify WAV exists and has reasonable duration | |
| wav_path = hw_marionette._dataset_dir / f"{move_id}.wav" | |
| assert wav_path.exists(), f"WAV file not found: {wav_path}" | |
| with wave.open(str(wav_path), "rb") as wf: | |
| wav_duration = wf.getnframes() / wf.getframerate() | |
| assert wav_duration > duration * 0.7, ( | |
| f"WAV too short: {wav_duration:.2f}s (expected ~{duration}s)" | |
| ) | |
| # Audio may include countdown buffering, so upper bound is generous. | |
| # The main check is that audio exists and isn't empty. | |
| assert wav_duration < duration + COUNTDOWN_SECONDS + 5.0, ( | |
| f"WAV too long: {wav_duration:.2f}s (expected ~{duration}s + countdown)" | |
| ) | |
| # Also verify the JSON file reports has_audio | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| move = next((m for m in state["moves"] if m["id"] == move_id), None) | |
| assert move is not None | |
| assert move["has_audio"] is True | |
| # Cleanup | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |
| def test_playback_with_audio_completes(self, base_url: str): | |
| """Play back a move that has audio, verify sound path is exercised.""" | |
| import httpx | |
| _ensure_idle(base_url) | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| move = next((m for m in state["moves"] if m["has_audio"]), None) | |
| if move is None: | |
| pytest.skip("No audio move available — audio recording may not be supported") | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move["id"]}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| _wait_for_mode(base_url, "idle", timeout=move["duration"] + 15) | |
| state = httpx.get(f"{base_url}/api/state", timeout=5).json() | |
| assert state["mode"] == "idle" | |
| class TestAntennaCollisionSync: | |
| """Test audio-motion sync using antenna collisions + laptop mic. | |
| Generates a move where beeps are synchronized with antenna collisions. | |
| Replays it on the robot, records via laptop mic, and measures the | |
| temporal offset between beep onsets and collision transients. | |
| Requires: laptop with microphone, sounddevice pip package. | |
| """ | |
| def test_playback_sync_via_mic(self, base_url: str, hw_marionette, hw_reachy): | |
| """Inject sync test move, play back, record mic, measure offsets.""" | |
| import httpx | |
| import soundfile as sf | |
| from audio_analysis import ( | |
| MicRecorder, | |
| detect_beep_onsets, | |
| detect_transient_onsets, | |
| generate_collision_trajectory, | |
| generate_sync_test_audio, | |
| measure_sync_offsets, | |
| ) | |
| _ensure_idle(base_url) | |
| duration = 8.0 | |
| beep_freq = 1000.0 | |
| # 1. Generate sync test audio + collision trajectory | |
| audio_data, beep_times = generate_sync_test_audio( | |
| duration=duration, beep_freq=beep_freq, | |
| ) | |
| timestamps, collision_frames = generate_collision_trajectory( | |
| beep_times, duration=duration, | |
| ) | |
| # 2. Write WAV + JSON to dataset dir | |
| move_id = "antenna-sync-test" | |
| wav_path = hw_marionette._dataset_dir / f"{move_id}.wav" | |
| json_path = hw_marionette._dataset_dir / f"{move_id}.json" | |
| sf.write(str(wav_path), audio_data, 48000) | |
| json_path.write_text(json.dumps({ | |
| "description": "Antenna collision sync test", | |
| "time": timestamps, | |
| "set_target_data": collision_frames, | |
| }), encoding="utf-8") | |
| hw_marionette._refresh_recordings() | |
| # 3. Start laptop mic recording | |
| recorder = MicRecorder(sr=48000) | |
| recorder.start() | |
| # Brief delay to ensure mic is capturing | |
| time.sleep(0.5) | |
| try: | |
| # 4. Trigger playback via API | |
| resp = httpx.post( | |
| f"{base_url}/api/play", | |
| json={"move_id": move_id}, | |
| timeout=5, | |
| ) | |
| assert resp.status_code == 200 | |
| # Wait for playback to finish | |
| _wait_for_mode(base_url, "idle", timeout=duration + 20) | |
| time.sleep(0.5) # Capture tail end | |
| finally: | |
| captured = recorder.stop() | |
| # 5. Analyze captured audio | |
| print(f"\nCaptured {len(captured)} samples ({len(captured)/48000:.2f}s)") | |
| beep_onsets = detect_beep_onsets(captured, 48000, freq=beep_freq) | |
| collision_onsets = detect_transient_onsets(captured, 48000) | |
| print(f"Detected {len(beep_onsets)} beeps: {[f'{t:.3f}s' for t in beep_onsets]}") | |
| print(f"Detected {len(collision_onsets)} collisions: {[f'{t:.3f}s' for t in collision_onsets]}") | |
| # 6. Measure sync quality | |
| result = measure_sync_offsets(beep_onsets, collision_onsets) | |
| print(f"Matched {result['n_matched']}/{result['n_beeps']} beeps") | |
| for bt, ct, offset in result["pairs"]: | |
| print(f" beep@{bt:.3f}s -> collision@{ct:.3f}s = {offset:+.1f}ms") | |
| print(f"Mean offset: {result['mean_offset_ms']:.1f}ms") | |
| print(f"Max offset: {result['max_offset_ms']:.1f}ms") | |
| print(f"Std offset: {result['std_offset_ms']:.1f}ms") | |
| # Assertions — generous thresholds for first iteration | |
| assert result["n_matched"] >= 3, ( | |
| f"Only matched {result['n_matched']}/{result['n_beeps']} beep-collision pairs" | |
| ) | |
| assert abs(result["mean_offset_ms"]) < 300, ( | |
| f"Mean sync offset {result['mean_offset_ms']:.1f}ms exceeds 300ms" | |
| ) | |
| assert result["max_offset_ms"] < 500, ( | |
| f"Max sync offset {result['max_offset_ms']:.1f}ms exceeds 500ms" | |
| ) | |
| # Cleanup | |
| httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5) | |