marionette / tests /test_hardware.py
RemiFabre
Refactor marionette into modules, fix audio sync, improve tests
dbc544f
Raw
History Blame
78.7 kB
"""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)