"""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 @pytest.fixture(scope="session") 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) @pytest.fixture(scope="session") def base_url(hardware_server): return hardware_server[0] @pytest.fixture(scope="session") def hw_marionette(hardware_server): return hardware_server[1] @pytest.fixture(scope="session") 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. """ @pytest.mark.parametrize("duration,label", [ (1.0, "short-1s"), (3.0, "medium-3s"), (5.0, "standard-5s"), (10.0, "long-10s"), ]) 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) @pytest.mark.parametrize("duration,label", [ (1.0, "play-short-1s"), (5.0, "play-standard-5s"), (10.0, "play-long-10s"), ]) 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)