Spaces:
Running
Running
RemiFabre commited on
Commit Β·
02264c2
1
Parent(s): 64dc954
fix: clap the RIGHT antenna into the held LEFT (was the non-colliding mirror)
Browse filesBoth 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.
- lib/robot-tapper.js +46 -37
- lib/version.js +1 -1
- 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 |
-
*
|
| 7 |
-
*
|
| 8 |
-
*
|
| 9 |
-
*
|
| 10 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
*
|
| 12 |
-
*
|
| 13 |
-
*
|
| 14 |
-
*
|
| 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 |
-
|
| 25 |
-
|
| 26 |
-
collisionDeg: 54, //
|
| 27 |
approachMs: 80, // ramp rest β collision
|
| 28 |
-
holdMs: 70, // dwell at collision (stall against
|
| 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 |
-
/**
|
| 43 |
-
|
| 44 |
-
const {
|
| 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
|
| 53 |
}
|
| 54 |
if (t <= hEnd) return collisionDeg;
|
| 55 |
const f = (t - hEnd) / this.p.returnMs;
|
| 56 |
-
return collisionDeg + (
|
| 57 |
}
|
| 58 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 71 |
const period = Math.max(10, Math.round(1000 / this.p.rateHz));
|
| 72 |
-
const endMs = (onsets.length ? onsets[onsets.length - 1] :
|
| 73 |
-
+ this.p.holdMs + this.p.returnMs +
|
| 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
|
| 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 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
| 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([
|
| 107 |
}
|
| 108 |
|
| 109 |
stop() {
|
|
@@ -111,7 +120,7 @@ export class RobotTapper {
|
|
| 111 |
clearInterval(this._timer);
|
| 112 |
this._timer = null;
|
| 113 |
}
|
| 114 |
-
|
| 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
|
|
|
|
| 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:
|
| 43 |
-
rangeRow("
|
| 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` }),
|