Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """Robot antenna-clap test for the Morse Code app (Lite / local daemon). | |
| Drives the antennas to clap on the app's impulse wire-code schedule (same code | |
| as lib/robot-tapper.js), records the laptop mic, runs the app's detector, and | |
| decodes — confirming the clap is audible + detectable and letting us tune the | |
| contact angle / hold / latency lead. Mirrors the proven, safe collision recipe | |
| from marionette/tests/test_antenna_collision.py (low-PID antennas stall at | |
| contact; gentle). | |
| MOVES THE ROBOT and MAKES NOISE. Run only when ready. | |
| /Users/remi/.virtualenvs/mini/bin/python tools/robot_clap_test.py --once | |
| /Users/remi/.virtualenvs/mini/bin/python tools/robot_clap_test.py --phrase "SOS" --unit 150 | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import math | |
| import sys | |
| import time | |
| from pathlib import Path | |
| import numpy as np | |
| import requests | |
| import sounddevice as sd | |
| TOOLS = Path(__file__).resolve().parent | |
| sys.path.insert(0, str(TOOLS)) | |
| from calibrate_audio import ( # noqa: E402 | |
| timing, text_to_onsets, onsets_to_text, detect_transient_onsets, SR, | |
| ) | |
| DEG = math.pi / 180.0 | |
| # Defaults mirror lib/robot-tapper.js DEFAULT_TAP_PROFILE. | |
| RIGHT_REST_DEG = -39.0 | |
| LEFT_REST_DEG = 0.0 | |
| COLLISION_DEG = 40.0 | |
| APPROACH_MS = 80.0 | |
| HOLD_MS = 50.0 | |
| RETURN_MS = 80.0 | |
| RATE_HZ = 50.0 | |
| LEAD_MS = 0.0 | |
| def left_angle_at(t_ms, onsets, collision, approach, hold, ret, rest): | |
| for T in onsets: | |
| a0, h_end, r_end = T - approach, T + hold, T + hold + ret | |
| if t_ms < a0 or t_ms > r_end: | |
| continue | |
| if t_ms <= T: | |
| f = (t_ms - a0) / approach if approach > 0 else 1.0 | |
| f = min(1.0, max(0.0, f)) | |
| return rest + (collision - rest) * (f * f) | |
| if t_ms <= h_end: | |
| return collision | |
| f = (t_ms - h_end) / ret | |
| f = min(1.0, max(0.0, f)) | |
| return collision + (rest - collision) * (1 - (1 - f) * (1 - f)) | |
| return rest | |
| def stop_current_app(): | |
| try: | |
| requests.post("http://127.0.0.1:8000/api/apps/stop-current-app", timeout=4) | |
| except Exception: | |
| pass | |
| def run(args): | |
| from reachy_mini import ReachyMini | |
| from reachy_mini.utils import create_head_pose | |
| t = timing(args.unit) | |
| onsets = [0.0] if args.once else text_to_onsets(args.phrase, t) | |
| onsets = [o - args.lead for o in onsets] | |
| collision = args.collision | |
| end_ms = (onsets[-1] if onsets else 0) + HOLD_MS + RETURN_MS + 300 | |
| rec_secs = (args.leadin_ms + end_ms + args.settle_ms + 1200) / 1000.0 | |
| print(f"Phrase {args.phrase!r} unit={args.unit}ms contact={collision}° " | |
| f"hold={args.hold}ms lead={args.lead}ms") | |
| print(f"Recording mic for {rec_secs:.1f}s; robot will clap {len(onsets)} time(s).") | |
| stop_current_app() | |
| time.sleep(0.5) | |
| r = ReachyMini(media_backend="no_media") | |
| try: | |
| r.goto_target(create_head_pose(), | |
| antennas=[LEFT_REST_DEG * DEG, RIGHT_REST_DEG * DEG], duration=1.0) | |
| time.sleep(1.2) | |
| rec = sd.rec(int(rec_secs * SR), samplerate=SR, channels=1, dtype="float32") | |
| leadin = args.leadin_ms / 1000.0 | |
| time.sleep(leadin) # silence head so the first onset has a quiet window | |
| period = max(0.01, 1.0 / RATE_HZ) | |
| t0 = time.monotonic() | |
| while True: | |
| tm = (time.monotonic() - t0) * 1000.0 | |
| if tm > end_ms: | |
| break | |
| left = left_angle_at(tm, onsets, collision, APPROACH_MS, args.hold, RETURN_MS, LEFT_REST_DEG) | |
| r.set_target(head=np.eye(4), body_yaw=0.0, | |
| antennas=np.array([left * DEG, RIGHT_REST_DEG * DEG])) | |
| nxt = t0 + (math.floor((tm / 1000.0) / period) + 1) * period | |
| dt = nxt - time.monotonic() | |
| if dt > 0: | |
| time.sleep(dt) | |
| # Let the final clap fully settle BEFORE any return motion — otherwise | |
| # the return goto jerks the antenna and rings out extra clicks right | |
| # after the last real onset (corrupting the last symbol). | |
| time.sleep(args.settle_ms / 1000.0) | |
| r.goto_target(create_head_pose(), | |
| antennas=[LEFT_REST_DEG * DEG, RIGHT_REST_DEG * DEG], duration=0.8) | |
| sd.wait() | |
| finally: | |
| try: | |
| r.goto_target(create_head_pose(), antennas=[0.0, 0.0], duration=0.8) | |
| time.sleep(1.0) | |
| except Exception: | |
| pass | |
| audio = rec.flatten() | |
| if args.save: | |
| audio.astype(np.float32).tofile(args.save) | |
| print(f"Saved raw float32 mono @{SR}Hz to {args.save} ({audio.size} samples)") | |
| detected = detect_transient_onsets(audio, SR, highpass_freq=args.highpass, | |
| threshold_db=20 * math.log10(args.ratio)) | |
| detected_ms = [d * 1000.0 for d in detected] | |
| peak = float(np.max(np.abs(audio))) if audio.size else 0.0 | |
| print(f"\nMic peak amplitude: {peak:.3f} (want clearly > the room floor)") | |
| print(f"Detected {len(detected_ms)} click(s); expected {len(onsets)}.") | |
| if not args.once: | |
| decoded = onsets_to_text(detected_ms, t) | |
| ok = decoded == args.phrase.upper() | |
| print(f"Decoded: {decoded!r} [{'OK' if ok else 'MISMATCH'}]") | |
| return 0 if ok else 2 | |
| print("Single-clap check: a click should be clearly visible above the floor.") | |
| return 0 if len(detected_ms) >= 1 else 2 | |
| def main(): | |
| ap = argparse.ArgumentParser(description="Robot antenna-clap test") | |
| ap.add_argument("--once", action="store_true", help="one gentle clap (safety check)") | |
| ap.add_argument("--phrase", default="SOS") | |
| ap.add_argument("--unit", type=int, default=150) | |
| ap.add_argument("--collision", type=float, default=COLLISION_DEG) | |
| ap.add_argument("--hold", type=float, default=HOLD_MS) | |
| ap.add_argument("--lead", type=float, default=LEAD_MS, help="advance timeline (ms)") | |
| ap.add_argument("--leadin-ms", type=float, default=600.0) | |
| ap.add_argument("--settle-ms", type=float, default=600.0, | |
| help="quiet settle after the last clap before returning to rest") | |
| ap.add_argument("--highpass", type=float, default=2000.0) | |
| ap.add_argument("--ratio", type=float, default=0.1) | |
| ap.add_argument("--save", default=None, help="save raw float32 mono recording to this path") | |
| return run(ap.parse_args()) | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |