RemiFabre commited on
Commit
02264c2
Β·
1 Parent(s): 64dc954

fix: clap the RIGHT antenna into the held LEFT (was the non-colliding mirror)

Browse files

Both SDKs order antennas [right, left] (init pose [-0.1745, +0.1745]). My
Python calibration wrote antennas=[a,b] assuming index 0 was LEFT β€” it is
RIGHT β€” so the motion I recorded clean decodes from was right-slams/left-held.
The JS app's setAntennasDeg(right,left) was called fixed-first/moving-second,
which slammed the LEFT antenna: the mirror image, which does NOT collide on
this robot (confirmed by RΓ©mi). Now the right antenna slams (0->54deg) into the
held left (-39deg), matching the validated motion. Re-confirmed on hardware:
strong click (mic peak 0.146 vs 0.004 for the mirror). Added a gentle preroll
ramp into the rest pose so the held antenna doesn't snap. Renamed profile
fields (heldDeg/slamRestDeg) + settings label to match.

Files changed (3) hide show
  1. lib/robot-tapper.js +46 -37
  2. lib/version.js +1 -1
  3. views/settings.js +2 -2
lib/robot-tapper.js CHANGED
@@ -3,33 +3,34 @@
3
  * onset produces an audible click. Consumes the same onset schedule from
4
  * wire.js that the speaker Synth uses, so robot and device speak one wire code.
5
  *
6
- * Physics (from marionette/tests/test_antenna_collision.py, proven safe):
7
- * - Right antenna held at a fixed angle.
8
- * - Left antenna slams in past the contact point and is commanded to hold
9
- * briefly β€” the low-PID antennas stall at contact (no damage) and make a
10
- * crisp click β€” then returns to rest.
 
 
 
 
 
 
11
  *
12
- * We drive it in real time with `reachy.setAntennasDeg(right, left)` at
13
- * ~50 Hz, shaping the left antenna's target as a function of time. All
14
- * geometry/timing is configurable so it can be calibrated on hardware
15
- * (contact angle, hold, command→sound latency) without touching logic.
16
  */
17
 
18
- // Calibrated on a Lite robot via tools/robot_clap_test.py (2026-06-01): a
19
- // firmer contact (54Β° vs the bare marionette 40Β°) made each clap clearly
20
- // audible/detectable to a laptop mic, and a settle tail after the last clap
21
- // stops a following return-to-pose from jerking the antenna and ringing out
22
- // spurious clicks that corrupt the final symbol.
23
  export const DEFAULT_TAP_PROFILE = Object.freeze({
24
- rightRestDeg: -39, // right antenna held here (β‰ˆ -0.68 rad)
25
- leftRestDeg: 0, // left antenna resting position
26
- collisionDeg: 54, // left commanded past contact (~34Β°) so it stalls + clicks
27
  approachMs: 80, // ramp rest β†’ collision
28
- holdMs: 70, // dwell at collision (stall against right antenna)
29
  returnMs: 80, // ramp back to rest
30
  rateHz: 50, // command rate
31
  leadMs: 0, // advance whole timeline to offset commandβ†’sound latency
32
  settleMs: 500, // quiet hold after the last clap before resolving (anti-ring)
 
33
  });
34
 
35
  export class RobotTapper {
@@ -39,9 +40,9 @@ export class RobotTapper {
39
  this._timer = null;
40
  }
41
 
42
- /** Left-antenna angle (deg) at time `t` ms, given the slam windows. */
43
- _leftAngleAt(t, onsets) {
44
- const { leftRestDeg, collisionDeg, approachMs, holdMs } = this.p;
45
  for (const T of onsets) {
46
  const a0 = T - approachMs; // start approaching
47
  const hEnd = T + holdMs; // end of hold
@@ -49,13 +50,18 @@ export class RobotTapper {
49
  if (t < a0 || t > rEnd) continue;
50
  if (t <= T) {
51
  const f = approachMs > 0 ? (t - a0) / approachMs : 1;
52
- return leftRestDeg + (collisionDeg - leftRestDeg) * easeIn(f);
53
  }
54
  if (t <= hEnd) return collisionDeg;
55
  const f = (t - hEnd) / this.p.returnMs;
56
- return collisionDeg + (leftRestDeg - collisionDeg) * easeOut(f);
57
  }
58
- return leftRestDeg;
 
 
 
 
 
59
  }
60
 
61
  /**
@@ -67,20 +73,21 @@ export class RobotTapper {
67
  * @returns {Promise<void>} resolves when the sequence completes/aborts
68
  */
69
  tap(onsetsMs, { onTap, signal } = {}) {
70
- const onsets = onsetsMs.map((t) => t - this.p.leadMs);
 
 
 
71
  const period = Math.max(10, Math.round(1000 / this.p.rateHz));
72
- const endMs = (onsets.length ? onsets[onsets.length - 1] : 0)
73
- + this.p.holdMs + this.p.returnMs + (this.p.settleMs ?? 500);
74
 
75
  return new Promise((resolve) => {
76
  const t0 = performance.now();
77
  let nextTapIdx = 0;
78
- const tapMarks = onsetsMs.slice(); // original (UI) onset times
79
  const finish = () => {
80
  clearInterval(this._timer);
81
  this._timer = null;
82
- // settle antennas back to rest
83
- try { this.reachy.setAntennasDeg(this.p.rightRestDeg, this.p.leftRestDeg); } catch { /* ignore */ }
84
  resolve();
85
  };
86
  signal?.addEventListener("abort", finish, { once: true });
@@ -88,11 +95,13 @@ export class RobotTapper {
88
  this._timer = setInterval(() => {
89
  const t = performance.now() - t0;
90
  if (t > endMs || signal?.aborted) return finish();
91
- const left = this._leftAngleAt(t, onsets);
92
- try {
93
- this.reachy.setAntennasDeg(this.p.rightRestDeg, left);
94
- } catch { /* transient send errors are non-fatal */ }
95
- // UI tap highlight when we cross an original onset time
 
 
96
  while (nextTapIdx < tapMarks.length && t >= tapMarks[nextTapIdx]) {
97
  onTap?.(nextTapIdx);
98
  nextTapIdx += 1;
@@ -103,7 +112,7 @@ export class RobotTapper {
103
 
104
  /** One calibration clap right now (no schedule). */
105
  async testClap() {
106
- await this.tap([100]);
107
  }
108
 
109
  stop() {
@@ -111,7 +120,7 @@ export class RobotTapper {
111
  clearInterval(this._timer);
112
  this._timer = null;
113
  }
114
- try { this.reachy.setAntennasDeg(this.p.rightRestDeg, this.p.leftRestDeg); } catch { /* ignore */ }
115
  }
116
  }
117
 
 
3
  * onset produces an audible click. Consumes the same onset schedule from
4
  * wire.js that the speaker Synth uses, so robot and device speak one wire code.
5
  *
6
+ * ── Antenna order: a bug worth remembering ───────────────────────────────
7
+ * Both the Python and JS SDKs order antennas as [right, left] (the init pose
8
+ * is [-0.1745, +0.1745] = [right, left]). When calibrating I drove the robot
9
+ * from a Python script and wrote `antennas=[a, b]` thinking index 0 was the
10
+ * LEFT antenna β€” it is actually the RIGHT. So the motion I recorded clean
11
+ * decodes from was: RIGHT antenna slams (0 β†’ +54Β°), LEFT antenna held (-39Β°).
12
+ * The first version of this file called `setAntennasDeg(right, left)` with the
13
+ * fixed value first and the moving value second, which slammed the LEFT
14
+ * antenna instead β€” the mirror image (still collided + clicked, but the wrong
15
+ * antenna moved). Fixed here: the RIGHT antenna (first arg) is the slammer, the
16
+ * LEFT (second arg) is held, matching the validated motion exactly.
17
  *
18
+ * Physics (from marionette/tests/test_antenna_collision.py): low-PID antennas
19
+ * stall at contact (no damage) and make a crisp click. All geometry/timing is
20
+ * configurable so it can be calibrated on hardware without touching logic.
 
21
  */
22
 
 
 
 
 
 
23
  export const DEFAULT_TAP_PROFILE = Object.freeze({
24
+ heldDeg: -39, // LEFT antenna, held still (β‰ˆ -0.68 rad)
25
+ slamRestDeg: 0, // RIGHT antenna resting position
26
+ collisionDeg: 54, // RIGHT antenna commanded past contact (~34Β°) so it stalls + clicks
27
  approachMs: 80, // ramp rest β†’ collision
28
+ holdMs: 70, // dwell at collision (stall against the held antenna)
29
  returnMs: 80, // ramp back to rest
30
  rateHz: 50, // command rate
31
  leadMs: 0, // advance whole timeline to offset commandβ†’sound latency
32
  settleMs: 500, // quiet hold after the last clap before resolving (anti-ring)
33
+ prerollMs: 500, // gentle move into the rest pose before the first clap
34
  });
35
 
36
  export class RobotTapper {
 
40
  this._timer = null;
41
  }
42
 
43
+ /** Slamming (RIGHT) antenna angle (deg) at time `t` ms within the windows. */
44
+ _slamAngleAt(t, onsets) {
45
+ const { slamRestDeg, collisionDeg, approachMs, holdMs } = this.p;
46
  for (const T of onsets) {
47
  const a0 = T - approachMs; // start approaching
48
  const hEnd = T + holdMs; // end of hold
 
50
  if (t < a0 || t > rEnd) continue;
51
  if (t <= T) {
52
  const f = approachMs > 0 ? (t - a0) / approachMs : 1;
53
+ return slamRestDeg + (collisionDeg - slamRestDeg) * easeIn(f);
54
  }
55
  if (t <= hEnd) return collisionDeg;
56
  const f = (t - hEnd) / this.p.returnMs;
57
+ return collisionDeg + (slamRestDeg - collisionDeg) * easeOut(f);
58
  }
59
+ return slamRestDeg;
60
+ }
61
+
62
+ _send(slam, held) {
63
+ // setAntennasDeg(right, left): RIGHT is the slammer, LEFT is held.
64
+ try { this.reachy.setAntennasDeg(slam, held); } catch { /* transient send errors are non-fatal */ }
65
  }
66
 
67
  /**
 
73
  * @returns {Promise<void>} resolves when the sequence completes/aborts
74
  */
75
  tap(onsetsMs, { onTap, signal } = {}) {
76
+ const preroll = this.p.prerollMs;
77
+ // Shift the schedule after the preroll, and apply the latency lead.
78
+ const onsets = onsetsMs.map((t) => t + preroll - this.p.leadMs);
79
+ const tapMarks = onsetsMs.map((t) => t + preroll); // for UI highlight
80
  const period = Math.max(10, Math.round(1000 / this.p.rateHz));
81
+ const endMs = (onsets.length ? onsets[onsets.length - 1] : preroll)
82
+ + this.p.holdMs + this.p.returnMs + this.p.settleMs;
83
 
84
  return new Promise((resolve) => {
85
  const t0 = performance.now();
86
  let nextTapIdx = 0;
 
87
  const finish = () => {
88
  clearInterval(this._timer);
89
  this._timer = null;
90
+ this._send(this.p.slamRestDeg, this.p.heldDeg); // settle at rest
 
91
  resolve();
92
  };
93
  signal?.addEventListener("abort", finish, { once: true });
 
95
  this._timer = setInterval(() => {
96
  const t = performance.now() - t0;
97
  if (t > endMs || signal?.aborted) return finish();
98
+ if (t < preroll) {
99
+ // Ease the held (LEFT) antenna into place; slammer waits at rest.
100
+ const f = preroll > 0 ? t / preroll : 1;
101
+ this._send(this.p.slamRestDeg, this.p.heldDeg * easeOut(f));
102
+ } else {
103
+ this._send(this._slamAngleAt(t, onsets), this.p.heldDeg);
104
+ }
105
  while (nextTapIdx < tapMarks.length && t >= tapMarks[nextTapIdx]) {
106
  onTap?.(nextTapIdx);
107
  nextTapIdx += 1;
 
112
 
113
  /** One calibration clap right now (no schedule). */
114
  async testClap() {
115
+ await this.tap([0]);
116
  }
117
 
118
  stop() {
 
120
  clearInterval(this._timer);
121
  this._timer = null;
122
  }
123
+ this._send(this.p.slamRestDeg, this.p.heldDeg);
124
  }
125
  }
126
 
lib/version.js CHANGED
@@ -1,3 +1,3 @@
1
  // Deployment version label, shown in Settings so you can verify which build
2
  // is actually loaded after a push. Format: "YYYY-MM-DD rN", bumped on push.
3
- export const APP_VERSION = "2026-06-01 r3 robot-calibrated";
 
1
  // Deployment version label, shown in Settings so you can verify which build
2
  // is actually loaded after a push. Format: "YYYY-MM-DD rN", bumped on push.
3
+ export const APP_VERSION = "2026-06-01 r4 antenna-fix + one-page";
views/settings.js CHANGED
@@ -39,8 +39,8 @@ export function createSettings(ctx) {
39
 
40
  el("h3", {}, "Robot clap (calibration)"),
41
  rangeRow("Contact angle", () => c.tap.collisionDeg, (v) => { c.tap.collisionDeg = v; },
42
- { min: 10, max: 55, step: 1, fmt: (v) => `${v}Β°` }),
43
- rangeRow("Right antenna", () => c.tap.rightRestDeg, (v) => { c.tap.rightRestDeg = v; },
44
  { min: -55, max: 0, step: 1, fmt: (v) => `${v}Β°` }),
45
  rangeRow("Hold", () => c.tap.holdMs, (v) => { c.tap.holdMs = v; },
46
  { min: 20, max: 150, step: 10, fmt: (v) => `${v} ms` }),
 
39
 
40
  el("h3", {}, "Robot clap (calibration)"),
41
  rangeRow("Contact angle", () => c.tap.collisionDeg, (v) => { c.tap.collisionDeg = v; },
42
+ { min: 10, max: 60, step: 1, fmt: (v) => `${v}Β°` }),
43
+ rangeRow("Held antenna", () => c.tap.heldDeg, (v) => { c.tap.heldDeg = v; },
44
  { min: -55, max: 0, step: 1, fmt: (v) => `${v}Β°` }),
45
  rangeRow("Hold", () => c.tap.holdMs, (v) => { c.tap.holdMs = v; },
46
  { min: 20, max: 150, step: 10, fmt: (v) => `${v} ms` }),