Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """End-to-end sync tests through the Marionette stack. | |
| Tests audio-motion synchronization by playing a synthetic move through | |
| Marionette's full playback pipeline (push_audio_sample + motion loop) | |
| and measuring beep-to-collision intervals with a laptop microphone. | |
| Requires: | |
| - Marionette running on the robot (via deploy_wireless.sh or daemon) | |
| - Laptop microphone connected and working | |
| - Robot accessible at --host (default: reachy-mini.local) | |
| Usage: | |
| python tests/test_marionette_sync.py [--host reachy-mini.local] | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import subprocess | |
| import sys | |
| import time | |
| from pathlib import Path | |
| import numpy as np | |
| import requests | |
| import sounddevice as sd | |
| import soundfile as sf | |
| sys.path.insert(0, str(Path(__file__).parent)) | |
| from audio_analysis import detect_beep_onsets, detect_transient_onsets | |
| # ── Timing (same as all previous sync tests) ────────────────────────── | |
| BEEP_TIMES = [1.0, 2.3, 4.0, 6.3, 9.4] | |
| BEEP_COLLISION_OFFSET = 1.0 | |
| COLLISION_TIMES = [t + BEEP_COLLISION_OFFSET for t in BEEP_TIMES] | |
| # Audio | |
| BEEP_FREQ = 2000.0 | |
| BEEP_DURATION = 0.2 | |
| BEEP_AMPLITUDE = 0.9 | |
| ROBOT_SR = 16000 | |
| # Collision | |
| RIGHT_REST = -0.68 | |
| LEFT_REST = 0.0 | |
| LEFT_COLLISION = 0.70 | |
| HOLD_DURATION = 0.2 | |
| MOTION_SR = 100 | |
| MOVE_ID = "sync-test-e2e" | |
| ROBOT_USER = "pollen" | |
| LAPTOP_SR = 48000 | |
| MIC_DURATION = 35.0 # longer: Marionette has goto + playback | |
| MARIONETTE_PORT = 8042 | |
| def get_marionette_url(host: str) -> str: | |
| return f"http://{host}:{MARIONETTE_PORT}" | |
| def wait_for_mode(base_url: str, target: str, timeout: float = 30.0) -> dict: | |
| """Poll GET /api/state until mode matches target.""" | |
| deadline = time.monotonic() + timeout | |
| while time.monotonic() < deadline: | |
| try: | |
| r = requests.get(f"{base_url}/api/state", timeout=3) | |
| state = r.json() | |
| if state.get("mode") == target: | |
| return state | |
| except Exception: | |
| pass | |
| time.sleep(0.3) | |
| raise TimeoutError(f"Mode never reached '{target}' within {timeout}s") | |
| def ensure_idle(base_url: str) -> dict: | |
| """Make sure Marionette is idle, stopping anything in progress.""" | |
| try: | |
| state = requests.get(f"{base_url}/api/state", timeout=3).json() | |
| except Exception as exc: | |
| raise RuntimeError(f"Cannot reach Marionette at {base_url}: {exc}") from exc | |
| mode = state.get("mode", "unknown") | |
| if mode == "idle": | |
| return state | |
| # Try to stop whatever is running | |
| if mode == "playing": | |
| requests.post(f"{base_url}/api/play/stop", timeout=3) | |
| elif mode in {"recording", "countdown", "preparing"}: | |
| requests.post(f"{base_url}/api/record/stop", timeout=3) | |
| return wait_for_mode(base_url, "idle", timeout=10) | |
| def generate_move_json() -> dict: | |
| """Generate Marionette-format move data with collision trajectory.""" | |
| total_duration = max(COLLISION_TIMES) + HOLD_DURATION + 1.0 | |
| dt = 1.0 / MOTION_SR | |
| n_frames = int(total_duration * MOTION_SR) | |
| identity_head = np.eye(4).tolist() | |
| left_targets = np.full(n_frames, LEFT_REST, dtype=np.float64) | |
| for ct in COLLISION_TIMES: | |
| start = int(ct * MOTION_SR) | |
| end = min(int((ct + HOLD_DURATION) * MOTION_SR), n_frames) | |
| left_targets[start:end] = LEFT_COLLISION | |
| timestamps = [] | |
| frames = [] | |
| for i in range(n_frames): | |
| t = round(i * dt, 4) | |
| timestamps.append(t) | |
| frames.append({ | |
| "head": identity_head, | |
| "antennas": [float(left_targets[i]), RIGHT_REST], | |
| "body_yaw": 0.0, | |
| "check_collision": False, | |
| }) | |
| return { | |
| "description": "E2E sync test: beeps + antenna collisions", | |
| "time": timestamps, | |
| "set_target_data": frames, | |
| } | |
| def generate_move_wav(path: Path) -> None: | |
| """Generate WAV with beeps at known times.""" | |
| total_duration = max(COLLISION_TIMES) + HOLD_DURATION + 1.0 | |
| n_audio = int(total_duration * ROBOT_SR) | |
| audio = np.zeros(n_audio, dtype=np.float32) | |
| for bt in BEEP_TIMES: | |
| start = int(bt * ROBOT_SR) | |
| n_beep = int(BEEP_DURATION * ROBOT_SR) | |
| if start + n_beep > n_audio: | |
| continue | |
| t_arr = np.arange(n_beep, dtype=np.float32) / ROBOT_SR | |
| beep = BEEP_AMPLITUDE * np.sin(2 * np.pi * BEEP_FREQ * t_arr).astype(np.float32) | |
| fade = int(0.005 * ROBOT_SR) | |
| if fade > 0 and 2 * fade < n_beep: | |
| beep[:fade] *= np.linspace(0, 1, fade, dtype=np.float32) | |
| beep[-fade:] *= np.linspace(1, 0, fade, dtype=np.float32) | |
| audio[start:start + n_beep] += beep | |
| sf.write(str(path), audio, ROBOT_SR) | |
| def inject_move(host: str, base_url: str) -> None: | |
| """Inject the synthetic move into the running Marionette's dataset.""" | |
| # Get the active dataset path from Marionette | |
| state = requests.get(f"{base_url}/api/state", timeout=3).json() | |
| dataset_path = state["config"]["active_dataset_path"] | |
| active_id = state.get("datasets", {}).get("active_id") | |
| print(f" Active dataset path: {dataset_path}") | |
| # Generate move files locally | |
| import tempfile | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| tmpdir = Path(tmpdir) | |
| json_path = tmpdir / f"{MOVE_ID}.json" | |
| wav_path = tmpdir / f"{MOVE_ID}.wav" | |
| move_data = generate_move_json() | |
| json_path.write_text(json.dumps(move_data), encoding="utf-8") | |
| generate_move_wav(wav_path) | |
| total_duration = max(COLLISION_TIMES) + HOLD_DURATION + 1.0 | |
| print(f" Generated move: {total_duration:.1f}s, {len(BEEP_TIMES)} beeps, {len(COLLISION_TIMES)} collisions") | |
| # SCP to robot's dataset directory | |
| for local_file in [json_path, wav_path]: | |
| remote_target = f"{ROBOT_USER}@{host}:{dataset_path}/{local_file.name}" | |
| result = subprocess.run( | |
| ["scp", "-o", "ConnectTimeout=5", str(local_file), remote_target], | |
| capture_output=True, text=True, timeout=15, | |
| ) | |
| if result.returncode != 0: | |
| raise RuntimeError(f"SCP failed: {result.stderr}") | |
| # Trigger Marionette to re-scan recordings by re-selecting the active dataset | |
| if active_id: | |
| requests.post( | |
| f"{base_url}/api/datasets/select", | |
| json={"dataset_id": active_id}, | |
| timeout=5, | |
| ) | |
| time.sleep(0.5) | |
| # Verify the move is visible | |
| state = requests.get(f"{base_url}/api/state", timeout=3).json() | |
| move_ids = [m["id"] for m in state.get("moves", [])] | |
| if MOVE_ID not in move_ids: | |
| raise RuntimeError( | |
| f"Move '{MOVE_ID}' not found after injection. " | |
| f"Available: {move_ids}" | |
| ) | |
| print(f" Move '{MOVE_ID}' injected and visible in Marionette") | |
| def cleanup_move(host: str, base_url: str) -> None: | |
| """Remove the synthetic test move.""" | |
| try: | |
| requests.delete(f"{base_url}/api/moves/{MOVE_ID}", timeout=5) | |
| except Exception: | |
| pass | |
| def plot_results( | |
| mic_audio, mic_sr, detected_beeps, detected_collisions, pairs, output_path, | |
| ): | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| mic_t = np.arange(len(mic_audio)) / mic_sr | |
| fig, ax = plt.subplots(1, 1, figsize=(18, 6)) | |
| ax.plot(mic_t, mic_audio, "k-", linewidth=0.3, alpha=0.5) | |
| ax.set_ylabel("Mic amplitude") | |
| ax.set_title("Marionette E2E Playback Sync Test — Laptop Mic Recording") | |
| ax.grid(True, alpha=0.3) | |
| for i, bt in enumerate(detected_beeps): | |
| ax.axvline(bt, color="blue", linestyle="-", linewidth=1.2, alpha=0.7, | |
| label="Detected beep" if i == 0 else None) | |
| for i, ct in enumerate(detected_collisions): | |
| ax.axvline(ct, color="red", linestyle="-", linewidth=1.2, alpha=0.7, | |
| label="Detected collision" if i == 0 else None) | |
| for p in pairs: | |
| mid = (p["beep_t"] + p["collision_t"]) / 2 | |
| ax.annotate(f'{p["interval_ms"]:.0f}ms', xy=(mid, 0), | |
| ha="center", fontsize=9, color="purple", fontweight="bold", | |
| bbox=dict(boxstyle="round,pad=0.2", facecolor="lightyellow", alpha=0.8)) | |
| ax.legend(loc="upper right", fontsize=9) | |
| all_events = detected_beeps + detected_collisions | |
| if all_events: | |
| ax.set_xlim(min(all_events) - 1.0, max(all_events) + 1.0) | |
| ax.set_xlabel("Time since mic start (s)") | |
| fig.tight_layout() | |
| fig.savefig(str(output_path), dpi=150) | |
| plt.close(fig) | |
| print(f" Plot saved to {output_path}") | |
| def run_playback_test(host: str) -> dict: | |
| """Run the Marionette playback sync test. | |
| Returns a results dict with pairs, errors, and verdict. | |
| """ | |
| base_url = get_marionette_url(host) | |
| print(f"\n{'='*60}") | |
| print("Marionette E2E Playback Sync Test") | |
| print(f"{'='*60}") | |
| print(f" Server: {base_url}") | |
| print(f" Beep times: {BEEP_TIMES}") | |
| print(f" Collision times: {COLLISION_TIMES}") | |
| print(f" Expected interval: {BEEP_COLLISION_OFFSET*1000:.0f}ms\n") | |
| # Step 1: Ensure idle | |
| print("[1/6] Ensuring Marionette is idle...") | |
| ensure_idle(base_url) | |
| print(" Idle.") | |
| # Step 2: Inject synthetic move | |
| print("[2/6] Injecting synthetic move into dataset...") | |
| inject_move(host, base_url) | |
| # Step 3: Start mic recording | |
| print(f"\n[3/6] Starting mic recording ({MIC_DURATION}s)...") | |
| mic_start = time.monotonic() | |
| mic_data = sd.rec( | |
| int(MIC_DURATION * LAPTOP_SR), | |
| samplerate=LAPTOP_SR, channels=1, dtype="float32", | |
| ) | |
| # Step 4: Trigger playback | |
| time.sleep(0.3) | |
| print("[4/6] Triggering playback via API...") | |
| play_start = time.monotonic() | |
| r = requests.post(f"{base_url}/api/play", json={"move_id": MOVE_ID}, timeout=5) | |
| if r.status_code != 200: | |
| sd.stop() | |
| raise RuntimeError(f"Play failed: {r.status_code} {r.text}") | |
| print(f" Play accepted at mic_t={play_start - mic_start:.3f}s") | |
| # Step 5: Wait for playback to finish | |
| print("[5/6] Waiting for playback to finish...") | |
| try: | |
| state = wait_for_mode(base_url, "idle", timeout=30) | |
| play_end = time.monotonic() | |
| print(f" Playback done at mic_t={play_end - mic_start:.3f}s") | |
| except TimeoutError: | |
| print(" WARNING: Playback did not finish in time") | |
| sd.wait() | |
| captured = mic_data.flatten() | |
| print(f" Mic recording done ({len(captured)/LAPTOP_SR:.1f}s)") | |
| mic_path = Path("tests/marionette_sync_mic.wav") | |
| sf.write(str(mic_path), captured, LAPTOP_SR) | |
| print(f" Saved to {mic_path}") | |
| # Step 6: Analyze | |
| print(f"\n[6/6] Analyzing...") | |
| detected_beeps = detect_beep_onsets( | |
| captured, LAPTOP_SR, freq=BEEP_FREQ, bandwidth=150.0, | |
| threshold_db=-12.0, min_separation=1.0, | |
| ) | |
| detected_collisions = detect_transient_onsets( | |
| captured, LAPTOP_SR, highpass_freq=3000.0, | |
| ) | |
| print(f" Detected {len(detected_beeps)} beeps at: {[f'{t:.3f}' for t in detected_beeps]}") | |
| print(f" Detected {len(detected_collisions)} collisions at: {[f'{t:.3f}' for t in detected_collisions]}") | |
| # Match pairs | |
| print(f"\n{'='*60}") | |
| print("Beep → Collision Interval Analysis (Marionette E2E)") | |
| print(f" (Expected interval: {BEEP_COLLISION_OFFSET*1000:.0f}ms)") | |
| print(f"{'='*60}") | |
| pairs = [] | |
| for i, bt in enumerate(detected_beeps): | |
| candidates = [ct for ct in detected_collisions if 0.3 < (ct - bt) < 2.0] | |
| if not candidates: | |
| print(f" Beep {i+1} at {bt:.3f}s: NO COLLISION FOUND") | |
| continue | |
| nearest = min(candidates, key=lambda ct: abs((ct - bt) - BEEP_COLLISION_OFFSET)) | |
| interval_ms = (nearest - bt) * 1000 | |
| error_ms = interval_ms - BEEP_COLLISION_OFFSET * 1000 | |
| pairs.append({ | |
| "beep_t": bt, | |
| "collision_t": nearest, | |
| "interval_ms": interval_ms, | |
| "error_ms": error_ms, | |
| }) | |
| print(f" Pair {len(pairs)}: beep {bt:.3f}s → collision {nearest:.3f}s = " | |
| f"{interval_ms:.0f}ms (error {error_ms:+.0f}ms)") | |
| result = { | |
| "test": "marionette_playback_sync", | |
| "n_beeps_detected": len(detected_beeps), | |
| "n_collisions_detected": len(detected_collisions), | |
| "n_pairs": len(pairs), | |
| "pairs": pairs, | |
| } | |
| if pairs: | |
| errors = [p["error_ms"] for p in pairs] | |
| intervals = [p["interval_ms"] for p in pairs] | |
| result["mean_interval_ms"] = float(np.mean(intervals)) | |
| result["mean_error_ms"] = float(np.mean(errors)) | |
| result["std_error_ms"] = float(np.std(errors)) | |
| result["min_error_ms"] = float(min(errors)) | |
| result["max_error_ms"] = float(max(errors)) | |
| print(f"\n Pairs matched: {len(pairs)}/{len(BEEP_TIMES)}") | |
| print(f" Mean interval: {np.mean(intervals):.0f}ms (expected {BEEP_COLLISION_OFFSET*1000:.0f}ms)") | |
| print(f" Mean error: {np.mean(errors):+.0f}ms") | |
| print(f" Std error: {np.std(errors):.0f}ms") | |
| print(f" Min/Max error: {min(errors):+.0f}ms / {max(errors):+.0f}ms") | |
| # Plot | |
| plot_path = Path("tests/marionette_sync_plot.png") | |
| plot_results(captured, LAPTOP_SR, detected_beeps, detected_collisions, pairs, plot_path) | |
| # Cleanup | |
| cleanup_move(host, base_url) | |
| success = len(pairs) >= len(BEEP_TIMES) - 1 | |
| result["success"] = success | |
| print(f"\n{'='*60}") | |
| if success: | |
| print("RESULT: PASS — Beep-collision pairs detected via Marionette E2E") | |
| else: | |
| print("RESULT: FAIL — Could not reliably detect pairs") | |
| print(f"{'='*60}\n") | |
| return result | |
| def run_recording_test(host: str) -> dict: | |
| """Test the 3-2-1 countdown timing accuracy. | |
| Triggers a recording with a known audio file, records with the | |
| laptop mic, and measures: | |
| - Countdown beep timing (3 beeps at 440Hz, 1s apart) | |
| - "Go" beep timing (880Hz) | |
| - When the uploaded audio actually starts playing | |
| """ | |
| base_url = get_marionette_url(host) | |
| print(f"\n{'='*60}") | |
| print("Marionette Recording Countdown Sync Test") | |
| print(f"{'='*60}") | |
| print(f" Server: {base_url}\n") | |
| # Step 1: Ensure idle | |
| print("[1/5] Ensuring Marionette is idle...") | |
| ensure_idle(base_url) | |
| # Step 2: Upload a known audio file (a single 2kHz beep at t=0.5) | |
| # This beep will play after the countdown, so we can measure | |
| # the delay from "go" beep to actual audio playback start. | |
| print("[2/5] Generating and uploading test audio...") | |
| import tempfile | |
| with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: | |
| test_wav = Path(f.name) | |
| # 3-second audio with a single 2kHz marker beep at t=0.2 | |
| # (early, so it's clearly after the go beep) | |
| marker_time = 0.2 | |
| audio_duration = 3.0 | |
| n_audio = int(audio_duration * 48000) # 48kHz for upload | |
| audio = np.zeros(n_audio, dtype=np.float32) | |
| start = int(marker_time * 48000) | |
| n_beep = int(BEEP_DURATION * 48000) | |
| t_arr = np.arange(n_beep, dtype=np.float32) / 48000 | |
| beep = BEEP_AMPLITUDE * np.sin(2 * np.pi * BEEP_FREQ * t_arr).astype(np.float32) | |
| fade = int(0.005 * 48000) | |
| if fade > 0: | |
| beep[:fade] *= np.linspace(0, 1, fade, dtype=np.float32) | |
| beep[-fade:] *= np.linspace(1, 0, fade, dtype=np.float32) | |
| audio[start:start + n_beep] = beep | |
| sf.write(str(test_wav), audio, 48000) | |
| print(f" Test audio: {audio_duration}s, marker beep at {marker_time}s (2kHz)") | |
| # Upload via Marionette API | |
| with open(test_wav, "rb") as f: | |
| r = requests.post( | |
| f"{base_url}/api/upload-audio", | |
| files={"file": ("countdown_test.wav", f, "audio/wav")}, | |
| timeout=10, | |
| ) | |
| test_wav.unlink() | |
| if r.status_code != 200: | |
| raise RuntimeError(f"Upload failed: {r.status_code} {r.text}") | |
| upload_info = r.json() | |
| upload_id = upload_info["upload_id"] | |
| print(f" Uploaded: id={upload_id}") | |
| # Step 3: Start mic recording | |
| print(f"\n[3/5] Starting mic recording (15s)...") | |
| mic_duration = 15.0 | |
| mic_start = time.monotonic() | |
| mic_data = sd.rec( | |
| int(mic_duration * LAPTOP_SR), | |
| samplerate=LAPTOP_SR, channels=1, dtype="float32", | |
| ) | |
| # Step 4: Trigger recording | |
| time.sleep(0.3) | |
| print("[4/5] Triggering recording with audio...") | |
| rec_start = time.monotonic() | |
| r = requests.post( | |
| f"{base_url}/api/record", | |
| json={ | |
| "label": "countdown-test", | |
| "duration": 3.0, | |
| "record_audio": False, | |
| "record_motion": True, | |
| "uploaded_audio_id": upload_id, | |
| }, | |
| timeout=5, | |
| ) | |
| if r.status_code != 200: | |
| sd.stop() | |
| raise RuntimeError(f"Record failed: {r.status_code} {r.text}") | |
| rec_move_id = r.json().get("move_id") | |
| print(f" Recording accepted at mic_t={rec_start - mic_start:.3f}s, move_id={rec_move_id}") | |
| # Wait for recording to finish (countdown ~3s + recording ~3s) | |
| print("[5/5] Waiting for recording to finish...") | |
| try: | |
| wait_for_mode(base_url, "idle", timeout=20) | |
| rec_end = time.monotonic() | |
| print(f" Recording done at mic_t={rec_end - mic_start:.3f}s") | |
| except TimeoutError: | |
| print(" WARNING: Recording did not finish in time") | |
| sd.wait() | |
| captured = mic_data.flatten() | |
| mic_path = Path("tests/marionette_countdown_mic.wav") | |
| sf.write(str(mic_path), captured, LAPTOP_SR) | |
| print(f" Saved to {mic_path}") | |
| # Analyze: detect countdown beeps (440Hz) and go beep (880Hz) and marker (2kHz) | |
| print(f"\n{'='*60}") | |
| print("Countdown Timing Analysis") | |
| print(f"{'='*60}") | |
| # Detect countdown beeps at 440Hz | |
| countdown_beeps = detect_beep_onsets( | |
| captured, LAPTOP_SR, freq=440.0, bandwidth=80.0, | |
| threshold_db=-12.0, min_separation=0.8, | |
| ) | |
| print(f" Countdown beeps (440Hz): {len(countdown_beeps)} at {[f'{t:.3f}' for t in countdown_beeps]}") | |
| # Detect "go" beep at 880Hz | |
| go_beeps = detect_beep_onsets( | |
| captured, LAPTOP_SR, freq=880.0, bandwidth=80.0, | |
| threshold_db=-12.0, min_separation=0.5, | |
| ) | |
| print(f" Go beep (880Hz): {len(go_beeps)} at {[f'{t:.3f}' for t in go_beeps]}") | |
| # Detect marker beep at 2kHz (from uploaded audio) | |
| marker_beeps = detect_beep_onsets( | |
| captured, LAPTOP_SR, freq=BEEP_FREQ, bandwidth=150.0, | |
| threshold_db=-12.0, min_separation=0.5, | |
| ) | |
| print(f" Marker beep (2kHz): {len(marker_beeps)} at {[f'{t:.3f}' for t in marker_beeps]}") | |
| result = { | |
| "test": "marionette_recording_countdown", | |
| "countdown_beeps": countdown_beeps, | |
| "go_beeps": go_beeps, | |
| "marker_beeps": marker_beeps, | |
| } | |
| # Analyze countdown spacing | |
| if len(countdown_beeps) >= 2: | |
| gaps = [countdown_beeps[i+1] - countdown_beeps[i] for i in range(len(countdown_beeps)-1)] | |
| print(f"\n Countdown gaps: {[f'{g:.3f}s' for g in gaps]} (expected ~1.0s each)") | |
| result["countdown_gaps"] = gaps | |
| gap_errors = [abs(g - 1.0) * 1000 for g in gaps] | |
| print(f" Gap errors: {[f'{e:.0f}ms' for e in gap_errors]}") | |
| # Analyze go-to-marker delay | |
| # Filter go beeps: discard any that overlap with countdown beeps (440Hz | |
| # harmonic leaks into the 880Hz band). The real "go" beep comes AFTER the | |
| # last countdown beep. | |
| if countdown_beeps and go_beeps: | |
| last_cd = countdown_beeps[-1] | |
| go_beeps_filtered = [t for t in go_beeps if t > last_cd + 0.3] | |
| print(f" Go beeps after countdown: {[f'{t:.3f}' for t in go_beeps_filtered]}") | |
| else: | |
| go_beeps_filtered = go_beeps | |
| if go_beeps_filtered and marker_beeps: | |
| go_t = go_beeps_filtered[0] | |
| marker_t = marker_beeps[0] | |
| delay_ms = (marker_t - go_t) * 1000 | |
| print(f"\n Go beep → Marker beep: {delay_ms:.0f}ms") | |
| print(f" (Marker is at {marker_time}s in audio file)") | |
| print(f" Expected if no pipeline latency: ~{marker_time*1000:.0f}ms") | |
| print(f" Extra delay (pipeline latency): ~{delay_ms - marker_time*1000:.0f}ms") | |
| result["go_to_marker_ms"] = delay_ms | |
| result["estimated_pipeline_latency_ms"] = delay_ms - marker_time * 1000 | |
| # Cleanup: delete the test recording | |
| if rec_move_id: | |
| try: | |
| requests.delete(f"{base_url}/api/moves/{rec_move_id}", timeout=5) | |
| except Exception: | |
| pass | |
| success = len(countdown_beeps) >= 2 and len(marker_beeps) >= 1 | |
| result["success"] = success | |
| print(f"\n{'='*60}") | |
| if success: | |
| print("RESULT: PASS — Countdown and marker beeps detected") | |
| else: | |
| print("RESULT: FAIL — Could not detect expected beeps") | |
| print(f"{'='*60}\n") | |
| return result | |
| def main(): | |
| parser = argparse.ArgumentParser(description="Marionette E2E sync tests") | |
| parser.add_argument("--host", default="reachy-mini.local") | |
| parser.add_argument("--test", choices=["playback", "recording", "both"], | |
| default="both", help="Which test to run") | |
| args = parser.parse_args() | |
| results = {} | |
| if args.test in ("playback", "both"): | |
| results["playback"] = run_playback_test(args.host) | |
| if args.test in ("recording", "both"): | |
| results["recording"] = run_recording_test(args.host) | |
| # Save results | |
| results_path = Path("tests/marionette_sync_results.json") | |
| results_path.write_text(json.dumps(results, indent=2, default=str)) | |
| print(f"\nResults saved to {results_path}") | |
| # Summary | |
| print(f"\n{'='*60}") | |
| print("SUMMARY") | |
| print(f"{'='*60}") | |
| for name, r in results.items(): | |
| status = "PASS" if r.get("success") else "FAIL" | |
| if name == "playback" and "mean_error_ms" in r: | |
| print(f" {name}: {status} — mean error {r['mean_error_ms']:+.0f}ms, " | |
| f"std {r['std_error_ms']:.0f}ms") | |
| elif name == "recording" and "go_to_marker_ms" in r: | |
| print(f" {name}: {status} — go→marker {r['go_to_marker_ms']:.0f}ms, " | |
| f"pipeline latency ~{r.get('estimated_pipeline_latency_ms', 0):.0f}ms") | |
| else: | |
| print(f" {name}: {status}") | |
| print(f"{'='*60}\n") | |
| all_pass = all(r.get("success", False) for r in results.values()) | |
| return 0 if all_pass else 1 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |