File size: 5,890 Bytes
843a4b2
 
 
 
 
02264c2
 
 
 
 
 
 
 
 
 
 
843a4b2
02264c2
 
 
843a4b2
 
 
02264c2
 
 
843a4b2
02264c2
843a4b2
 
 
fa08634
02264c2
843a4b2
 
 
 
 
 
 
 
 
02264c2
 
 
843a4b2
 
 
 
 
 
 
02264c2
843a4b2
 
 
02264c2
843a4b2
02264c2
 
 
 
 
 
843a4b2
 
 
 
 
 
 
 
 
 
 
02264c2
 
 
 
843a4b2
02264c2
 
843a4b2
 
 
 
 
 
 
02264c2
843a4b2
 
 
 
 
 
 
02264c2
 
 
 
 
 
 
843a4b2
 
 
 
 
 
 
 
 
 
02264c2
843a4b2
 
 
 
 
 
 
02264c2
843a4b2
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
/**
 * Robot transmitter: claps the two antennas together on schedule so each
 * onset produces an audible click. Consumes the same onset schedule from
 * wire.js that the speaker Synth uses, so robot and device speak one wire code.
 *
 * ── Antenna order: a bug worth remembering ───────────────────────────────
 * Both the Python and JS SDKs order antennas as [right, left] (the init pose
 * is [-0.1745, +0.1745] = [right, left]). When calibrating I drove the robot
 * from a Python script and wrote `antennas=[a, b]` thinking index 0 was the
 * LEFT antenna β€” it is actually the RIGHT. So the motion I recorded clean
 * decodes from was: RIGHT antenna slams (0 β†’ +54Β°), LEFT antenna held (-39Β°).
 * The first version of this file called `setAntennasDeg(right, left)` with the
 * fixed value first and the moving value second, which slammed the LEFT
 * antenna instead β€” the mirror image (still collided + clicked, but the wrong
 * antenna moved). Fixed here: the RIGHT antenna (first arg) is the slammer, the
 * LEFT (second arg) is held, matching the validated motion exactly.
 *
 * Physics (from marionette/tests/test_antenna_collision.py): low-PID antennas
 * stall at contact (no damage) and make a crisp click. All geometry/timing is
 * configurable so it can be calibrated on hardware without touching logic.
 */

export const DEFAULT_TAP_PROFILE = Object.freeze({
    heldDeg: -39, // LEFT antenna, held still (β‰ˆ -0.68 rad)
    slamRestDeg: 0, // RIGHT antenna resting position
    collisionDeg: 54, // RIGHT antenna commanded past contact (~34Β°) so it stalls + clicks
    approachMs: 80, // ramp rest β†’ collision
    holdMs: 70, // dwell at collision (stall against the held antenna)
    returnMs: 80, // ramp back to rest
    rateHz: 50, // command rate
    leadMs: 0, // advance whole timeline to offset command→sound latency
    settleMs: 500, // quiet hold after the last clap before resolving (anti-ring)
    prerollMs: 500, // gentle move into the rest pose before the first clap
});

export class RobotTapper {
    constructor(reachy, profile = {}) {
        this.reachy = reachy;
        this.p = { ...DEFAULT_TAP_PROFILE, ...profile };
        this._timer = null;
    }

    /** Slamming (RIGHT) antenna angle (deg) at time `t` ms within the windows. */
    _slamAngleAt(t, onsets) {
        const { slamRestDeg, collisionDeg, approachMs, holdMs } = this.p;
        for (const T of onsets) {
            const a0 = T - approachMs; // start approaching
            const hEnd = T + holdMs; // end of hold
            const rEnd = hEnd + this.p.returnMs; // back to rest
            if (t < a0 || t > rEnd) continue;
            if (t <= T) {
                const f = approachMs > 0 ? (t - a0) / approachMs : 1;
                return slamRestDeg + (collisionDeg - slamRestDeg) * easeIn(f);
            }
            if (t <= hEnd) return collisionDeg;
            const f = (t - hEnd) / this.p.returnMs;
            return collisionDeg + (slamRestDeg - collisionDeg) * easeOut(f);
        }
        return slamRestDeg;
    }

    _send(slam, held) {
        // setAntennasDeg(right, left): RIGHT is the slammer, LEFT is held.
        try { this.reachy.setAntennasDeg(slam, held); } catch { /* transient send errors are non-fatal */ }
    }

    /**
     * Run the clap timeline.
     * @param {number[]} onsetsMs
     * @param {object} [o]
     * @param {(i:number)=>void} [o.onTap] fires near each impact (UI highlight)
     * @param {AbortSignal} [o.signal]
     * @returns {Promise<void>} resolves when the sequence completes/aborts
     */
    tap(onsetsMs, { onTap, signal } = {}) {
        const preroll = this.p.prerollMs;
        // Shift the schedule after the preroll, and apply the latency lead.
        const onsets = onsetsMs.map((t) => t + preroll - this.p.leadMs);
        const tapMarks = onsetsMs.map((t) => t + preroll); // for UI highlight
        const period = Math.max(10, Math.round(1000 / this.p.rateHz));
        const endMs = (onsets.length ? onsets[onsets.length - 1] : preroll)
            + this.p.holdMs + this.p.returnMs + this.p.settleMs;

        return new Promise((resolve) => {
            const t0 = performance.now();
            let nextTapIdx = 0;
            const finish = () => {
                clearInterval(this._timer);
                this._timer = null;
                this._send(this.p.slamRestDeg, this.p.heldDeg); // settle at rest
                resolve();
            };
            signal?.addEventListener("abort", finish, { once: true });

            this._timer = setInterval(() => {
                const t = performance.now() - t0;
                if (t > endMs || signal?.aborted) return finish();
                if (t < preroll) {
                    // Ease the held (LEFT) antenna into place; slammer waits at rest.
                    const f = preroll > 0 ? t / preroll : 1;
                    this._send(this.p.slamRestDeg, this.p.heldDeg * easeOut(f));
                } else {
                    this._send(this._slamAngleAt(t, onsets), this.p.heldDeg);
                }
                while (nextTapIdx < tapMarks.length && t >= tapMarks[nextTapIdx]) {
                    onTap?.(nextTapIdx);
                    nextTapIdx += 1;
                }
            }, period);
        });
    }

    /** One calibration clap right now (no schedule). */
    async testClap() {
        await this.tap([0]);
    }

    stop() {
        if (this._timer) {
            clearInterval(this._timer);
            this._timer = null;
        }
        this._send(this.p.slamRestDeg, this.p.heldDeg);
    }
}

function easeIn(x) {
    const c = Math.min(1, Math.max(0, x));
    return c * c; // accelerate into the impact
}
function easeOut(x) {
    const c = Math.min(1, Math.max(0, x));
    return 1 - (1 - c) * (1 - c);
}