marionette / tests /test_marionette_sync.py
RemiFabre
Refactor marionette into modules, fix audio sync, improve tests
dbc544f
Raw
History Blame
22.3 kB
#!/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())