RemiFabre commited on
Commit
75f7407
·
verified ·
1 Parent(s): f5fc199

Reachy: the official head wobbler (speech tapper port) and silent emotion moves

Browse files

Ports the official Reachy Mini head wobbler into the browser simulator and makes the Reachy emotion moves silent.

- `source/src/game/reachy/speechTapper.js`: a line-for-line port of `reachy_mini/motion/speech_tapper.py` (SwayRollRT: 16 kHz PCM, 20 ms frames, 50 ms hops, the VAD with hysteresis and attack/release, the loudness curve, the six oscillators with the daemon's seeded phases).
- `source/src/game/reachy/headWobbler.js`: runs the tapper offline on a decoded line (for wav lines) and, for the browser's speech synthesis (no samples), on a synthetic voice: voiced bursts at a natural syllable rate (~4 Hz, jittered) while the utterance plays, silence otherwise. Replaces the fixed sine sway.
- `reachyController.js`: the tapper's offsets are composed on the head target in the world frame before the IK exactly as the daemon's backend does (`compose_world_offset`: R = R_off R_abs, t = t_abs + t_off).
- `emotionController.js`: `reachySay` drives the wobbler while speaking; the Reachy emotion moves play SILENT (movement only, no .ogg), as the real robot player does with `play_move(sound=False)`: only Reachy's spoken voice is heard. The .ogg files are still loaded harmlessly by nothing; they can stay.

Tested in RemiFabre/microduck-reachy-simulator (same code at the root `src/` layout there); recorded videos of the conversation script and of a two-robot scene look right: small syllable-synchronous head motion while Reachy speaks, no wobble on the moves.

source/src/game/emotions/emotionController.js CHANGED
@@ -2,13 +2,16 @@ import { playMicroduckEmotion } from "../microduck/microduckEmotions.js";
2
  import { EMOTIONS, loadReachyEmotion } from "./emotions.js";
3
  import { playUrl } from "../audio.js";
4
  import { signed } from "../signed.js";
 
5
 
6
  let selectedReachyVoice = null;
7
 
8
  export function reachySay(text, reachy = null) {
9
  try { globalThis.dispatchEvent?.(new CustomEvent("reachy-speech-start", { detail: { text: String(text) } })); } catch { /* recording hook is optional */ }
10
  if (!globalThis.speechSynthesis || !globalThis.SpeechSynthesisUtterance) return Promise.resolve();
11
- reachy?.setWobbler?.(true, "speech");
 
 
12
  return new Promise((resolve) => {
13
  let spoken = false;
14
  const speak = () => {
@@ -26,7 +29,7 @@ export function reachySay(text, reachy = null) {
26
  // Same playful childlike profile for every sentence.
27
  utterance.pitch = 1.28;
28
  utterance.rate = 1.02;
29
- const done = () => { reachy?.setWobbler?.(false, "speech"); resolve(); };
30
  utterance.onend = done;
31
  utterance.onerror = done;
32
  speechSynthesis.cancel();
@@ -85,9 +88,8 @@ export function createEmotionController({ runtime, reachy, setState }) {
85
 
86
  async function playReachy(emotion, definition) {
87
  const motion = await loadReachyEmotion(definition.motion);
88
- playUrl(signed(`./robot/reachy/emotions/${definition.motion}.ogg`), { gain: 0.9 });
89
- reachy.setWobbler(true, "emotion");
90
- try { await reachy.playMotion(motion); }
91
- finally { reachy.setWobbler(false, "emotion"); }
92
  }
93
  }
 
2
  import { EMOTIONS, loadReachyEmotion } from "./emotions.js";
3
  import { playUrl } from "../audio.js";
4
  import { signed } from "../signed.js";
5
+ import { createSyntheticSpeech } from "../reachy/headWobbler.js";
6
 
7
  let selectedReachyVoice = null;
8
 
9
  export function reachySay(text, reachy = null) {
10
  try { globalThis.dispatchEvent?.(new CustomEvent("reachy-speech-start", { detail: { text: String(text) } })); } catch { /* recording hook is optional */ }
11
  if (!globalThis.speechSynthesis || !globalThis.SpeechSynthesisUtterance) return Promise.resolve();
12
+ // The official head wobbler (the daemon's speech tapper) on a synthetic voice: speech synthesis
13
+ // gives no samples to tap, so voiced bursts at a syllable rate stand in while the line plays.
14
+ const stopTaps = reachy?.setSpeechOffsets ? createSyntheticSpeech((o) => reachy.setSpeechOffsets(o)) : () => {};
15
  return new Promise((resolve) => {
16
  let spoken = false;
17
  const speak = () => {
 
29
  // Same playful childlike profile for every sentence.
30
  utterance.pitch = 1.28;
31
  utterance.rate = 1.02;
32
+ const done = () => { stopTaps(); resolve(); };
33
  utterance.onend = done;
34
  utterance.onerror = done;
35
  speechSynthesis.cancel();
 
88
 
89
  async function playReachy(emotion, definition) {
90
  const motion = await loadReachyEmotion(definition.motion);
91
+ // Movement only: the real player plays the library moves silent (`play_move(sound=False)`),
92
+ // only Reachy's spoken voice is heard; the wobble follows the voice, not the move.
93
+ await reachy.playMotion(motion);
 
94
  }
95
  }
source/src/game/reachy/headWobbler.js ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The official head wobbler, offline: the daemon feeds the speaker's PCM (resampled to 16 kHz)
2
+ // through the speech tapper and applies each 50 ms hop's offsets at that hop's playback time.
3
+ // Here the whole line is known in advance, so the hops are computed once from the decoded wav
4
+ // and applied against the audio clock while it plays (same timeline, deterministic).
5
+ import { SwayRollRT, HOP_MS } from "./speechTapper.js";
6
+
7
+ export const ZERO_OFFSETS = Object.freeze({ x: 0, y: 0, z: 0, roll: 0, pitch: 0, yaw: 0 });
8
+ const TAPPER_RATE = 16000;
9
+
10
+ // AudioBuffer -> [{x,y,z,roll,pitch,yaw}] per 50 ms hop, from t = 0 of the buffer.
11
+ export async function analyseLine(buffer) {
12
+ let pcm;
13
+ if (buffer.sampleRate === TAPPER_RATE && buffer.numberOfChannels === 1) pcm = buffer.getChannelData(0);
14
+ else {
15
+ const frames = Math.ceil(buffer.duration * TAPPER_RATE);
16
+ const off = new OfflineAudioContext(1, frames, TAPPER_RATE);
17
+ const src = off.createBufferSource(); src.buffer = buffer; src.connect(off.destination); src.start();
18
+ pcm = (await off.startRendering()).getChannelData(0);
19
+ }
20
+ return new SwayRollRT(TAPPER_RATE).feed(pcm);
21
+ }
22
+
23
+ // The hop in force `seconds` into the line (the daemon fires hop i at play_at + i * 50 ms).
24
+ export function offsetsAt(hops, seconds) {
25
+ if (!hops?.length || seconds < 0) return ZERO_OFFSETS;
26
+ const i = Math.floor(seconds * 1000 / HOP_MS);
27
+ return i < hops.length ? hops[i] : ZERO_OFFSETS;
28
+ }
29
+
30
+ // Speech without samples (the browser's speech synthesis): feed the tapper a synthetic voice
31
+ // instead of a fixed sine, so the wobble keeps the official character (VAD, syllable-rate taps,
32
+ // loudness curve) while an utterance plays. Voiced bursts at a natural syllable rate, ~4 Hz with
33
+ // jitter, 55-65 % duty, noise at a speech-like level; silence otherwise. One hop (50 ms) per tick.
34
+ export function createSyntheticSpeech(apply) {
35
+ const tapper = new SwayRollRT(TAPPER_RATE);
36
+ const hop = Math.floor(TAPPER_RATE * HOP_MS / 1000);
37
+ let t = 0, nextSyllable = 0, voicedUntil = 0, level = 0.09, seed = 12345;
38
+ const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
39
+ const timer = setInterval(() => {
40
+ if (t >= nextSyllable) {
41
+ const period = 0.25 * (0.8 + 0.4 * rnd()); // ~4 Hz, jittered
42
+ voicedUntil = t + period * (0.55 + 0.1 * rnd());
43
+ nextSyllable = t + period;
44
+ level = 0.06 + 0.06 * rnd(); // -25 .. -19 dBFS rms
45
+ if (rnd() < 0.12) nextSyllable += 0.3; // a breath between phrases now and then
46
+ }
47
+ const voiced = t < voicedUntil;
48
+ const pcm = new Float32Array(hop);
49
+ const a = voiced ? level : 0.0007;
50
+ for (let i = 0; i < hop; i++) pcm[i] = a * (rnd() * 2 - 1) * 1.7;
51
+ for (const o of tapper.feed(pcm)) apply(o);
52
+ t += HOP_MS / 1000;
53
+ }, HOP_MS);
54
+ return () => { clearInterval(timer); apply(ZERO_OFFSETS); };
55
+ }
source/src/game/reachy/reachyController.js CHANGED
@@ -1,7 +1,7 @@
1
  import { MOTOR_NAMES, HOME, INITIAL_MOTOR_POSITIONS, poseToTarget, interpolateTarget, createReachyKinematics } from "./reachyKinematics.js";
2
  import { REACHY_PREFIX } from "./reachyWorld.js";
3
  import { abortError } from "../simulationTimeline.js";
4
- import { Matrix4 } from "three";
5
 
6
  export function createReachyController({ mujoco, getWorld, calibration, timeline, onState = () => {}, isLocked = () => false }) {
7
  const ik = createReachyKinematics(calibration);
@@ -9,6 +9,10 @@ export function createReachyController({ mujoco, getWorld, calibration, timeline
9
  let boundModel, addresses = [];
10
  let active = null, queue = [], speed = 1;
11
  const wobblerSources = new Set();
 
 
 
 
12
  const publish = (extra = {}) => onState({ playing: !!active, queue: queue.map((q) => q.label), speed, ...extra });
13
  function apply(next) {
14
  const motors = ik(next); // validate the complete target before changing any motor
@@ -26,9 +30,16 @@ export function createReachyController({ mujoco, getWorld, calibration, timeline
26
  boundModel = model;
27
  }
28
  let output = motorTargets;
29
- if (wobblerSources.size) {
30
- const wobbleTarget = { ...target, head: target.head.clone().multiply(new Matrix4().makeRotationZ(Math.sin(performance.now() * 0.012) * 0.055)) };
31
- output = ik(wobbleTarget);
 
 
 
 
 
 
 
32
  }
33
  addresses.forEach((id, i) => { data.ctrl[id] = output[i]; });
34
  }
@@ -62,6 +73,8 @@ export function createReachyController({ mujoco, getWorld, calibration, timeline
62
  if (value) wobblerSources.add(source);
63
  else wobblerSources.delete(source);
64
  },
 
 
65
  get target() { return target; },
66
  get motorTargets() { return [...motorTargets]; },
67
  stop() { active?.abort(); },
 
1
  import { MOTOR_NAMES, HOME, INITIAL_MOTOR_POSITIONS, poseToTarget, interpolateTarget, createReachyKinematics } from "./reachyKinematics.js";
2
  import { REACHY_PREFIX } from "./reachyWorld.js";
3
  import { abortError } from "../simulationTimeline.js";
4
+ import { Matrix4, Euler } from "three";
5
 
6
  export function createReachyController({ mujoco, getWorld, calibration, timeline, onState = () => {}, isLocked = () => false }) {
7
  const ik = createReachyKinematics(calibration);
 
9
  let boundModel, addresses = [];
10
  let active = null, queue = [], speed = 1;
11
  const wobblerSources = new Set();
12
+ // The official head wobbler's per-hop offsets (reachy_mini/motion/speech_tapper.py, ported in
13
+ // ./speechTapper.js): {x, y, z} metres, {roll, pitch, yaw} radians, composed in the world frame
14
+ // before the IK exactly as the daemon does (R = R_off R_abs, t = t_abs + t_off).
15
+ let speechOffsets = { x: 0, y: 0, z: 0, roll: 0, pitch: 0, yaw: 0 };
16
  const publish = (extra = {}) => onState({ playing: !!active, queue: queue.map((q) => q.label), speed, ...extra });
17
  function apply(next) {
18
  const motors = ik(next); // validate the complete target before changing any motor
 
30
  boundModel = model;
31
  }
32
  let output = motorTargets;
33
+ const o = speechOffsets;
34
+ if (o.x || o.y || o.z || o.roll || o.pitch || o.yaw) {
35
+ const rot = new Matrix4().makeRotationFromEuler(new Euler(o.roll, o.pitch, o.yaw, "ZYX"));
36
+ const head = target.head.clone();
37
+ const e = head.elements;
38
+ const tx = e[12], ty = e[13], tz = e[14];
39
+ head.setPosition(0, 0, 0);
40
+ head.premultiply(rot);
41
+ head.setPosition(tx + o.x, ty + o.y, tz + o.z);
42
+ try { output = ik({ ...target, head }); } catch { output = motorTargets; }
43
  }
44
  addresses.forEach((id, i) => { data.ctrl[id] = output[i]; });
45
  }
 
73
  if (value) wobblerSources.add(source);
74
  else wobblerSources.delete(source);
75
  },
76
+ // The official wobbler's offsets for this hop (see ./headWobbler.js).
77
+ setSpeechOffsets(offsets) { speechOffsets = offsets || { x: 0, y: 0, z: 0, roll: 0, pitch: 0, yaw: 0 }; },
78
  get target() { return target; },
79
  get motorTargets() { return [...motorTargets]; },
80
  stop() { active?.abort(); },
source/src/game/reachy/speechTapper.js ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Port of the OFFICIAL Reachy Mini speech tapper (reachy_mini/motion/speech_tapper.py,
2
+ // "SwayRollRT"): 16 kHz float PCM in, one sway dict per 50 ms hop out. Same tunables, same
3
+ // VAD (hysteresis + attack / release), same loudness curve, same six oscillators; the seeded
4
+ // oscillator phases are the numpy default_rng(7) values the daemon draws.
5
+ export const FRAME_MS = 20;
6
+ export const HOP_MS = 50;
7
+ const SWAY_MASTER = 1.5;
8
+ const SENS_DB_OFFSET = 4.0;
9
+ const VAD_DB_ON = -35.0, VAD_DB_OFF = -45.0;
10
+ const VAD_ATTACK_MS = 40, VAD_RELEASE_MS = 250;
11
+ const ENV_FOLLOW_GAIN = 0.65;
12
+ const SWAY_F_PITCH = 2.2, SWAY_A_PITCH_DEG = 4.5;
13
+ const SWAY_F_YAW = 0.6, SWAY_A_YAW_DEG = 7.5;
14
+ const SWAY_F_ROLL = 1.3, SWAY_A_ROLL_DEG = 2.25;
15
+ const SWAY_F_X = 0.35, SWAY_A_X_MM = 4.5;
16
+ const SWAY_F_Y = 0.45, SWAY_A_Y_MM = 3.75;
17
+ const SWAY_F_Z = 0.25, SWAY_A_Z_MM = 2.25;
18
+ const SWAY_DB_LOW = -46.0, SWAY_DB_HIGH = -18.0;
19
+ const LOUDNESS_GAMMA = 0.9;
20
+ const SWAY_ATTACK_MS = 50, SWAY_RELEASE_MS = 250;
21
+ const ATTACK_FR = Math.max(1, Math.floor(VAD_ATTACK_MS / HOP_MS));
22
+ const RELEASE_FR = Math.max(1, Math.floor(VAD_RELEASE_MS / HOP_MS));
23
+ const SWAY_ATTACK_FR = Math.max(1, Math.floor(SWAY_ATTACK_MS / HOP_MS));
24
+ const SWAY_RELEASE_FR = Math.max(1, Math.floor(SWAY_RELEASE_MS / HOP_MS));
25
+ // np.random.default_rng(7).random() * 2 pi, six draws, in the daemon's order (pitch, yaw, roll, x, y, z).
26
+ const PHASES = [3.927590651355011, 5.637360571650786, 4.873776931938056, 1.4150185072200883, 1.8860003910648933, 5.488698173149897];
27
+ const rad = Math.PI / 180;
28
+
29
+ function rmsDbfs(x) {
30
+ let s = 0;
31
+ for (let i = 0; i < x.length; i++) s += x[i] * x[i];
32
+ const rms = Math.sqrt(s / x.length + 1e-12);
33
+ return 20 * Math.log10(rms + 1e-12);
34
+ }
35
+ function loudnessGain(db) {
36
+ let t = (db + SENS_DB_OFFSET - SWAY_DB_LOW) / (SWAY_DB_HIGH - SWAY_DB_LOW);
37
+ t = Math.min(1, Math.max(0, t));
38
+ return LOUDNESS_GAMMA !== 1 ? t ** LOUDNESS_GAMMA : t;
39
+ }
40
+
41
+ export class SwayRollRT {
42
+ constructor(sampleRate = 16000) {
43
+ this.sampleRate = sampleRate | 0;
44
+ this.frame = Math.floor(this.sampleRate * FRAME_MS / 1000);
45
+ this.hop = Math.floor(this.sampleRate * HOP_MS / 1000);
46
+ this.reset();
47
+ }
48
+ reset() {
49
+ this.samples = new Float32Array(0); this.carry = new Float32Array(0);
50
+ this.vadOn = false; this.vadAbove = 0; this.vadBelow = 0;
51
+ this.swayEnv = 0; this.swayUp = 0; this.swayDown = 0; this.t = 0;
52
+ }
53
+ // Feed float32 mono PCM at this.sampleRate; returns [{pitch, yaw, roll, x, y, z}] per hop
54
+ // (radians and metres, the units the daemon composes on the head pose).
55
+ feed(pcm) {
56
+ if (!pcm.length) return [];
57
+ if (this.carry.length) { const c = new Float32Array(this.carry.length + pcm.length); c.set(this.carry); c.set(pcm, this.carry.length); this.carry = c; }
58
+ else this.carry = pcm;
59
+ const out = [];
60
+ while (this.carry.length >= this.hop) {
61
+ const hop = this.carry.subarray(0, this.hop);
62
+ this.carry = this.carry.subarray(this.hop);
63
+ if (this.samples.length) { const c = new Float32Array(this.samples.length + hop.length); c.set(this.samples); c.set(hop, this.samples.length); this.samples = c.subarray(Math.max(0, c.length - this.frame)); }
64
+ else this.samples = hop.subarray(Math.max(0, hop.length - this.frame)).slice();
65
+ if (this.samples.length < this.frame) { this.t += HOP_MS / 1000; continue; }
66
+ const db = rmsDbfs(this.samples);
67
+ if (db >= VAD_DB_ON) { this.vadAbove += 1; this.vadBelow = 0; if (!this.vadOn && this.vadAbove >= ATTACK_FR) this.vadOn = true; }
68
+ else if (db <= VAD_DB_OFF) { this.vadBelow += 1; this.vadAbove = 0; if (this.vadOn && this.vadBelow >= RELEASE_FR) this.vadOn = false; }
69
+ if (this.vadOn) { this.swayUp = Math.min(SWAY_ATTACK_FR, this.swayUp + 1); this.swayDown = 0; }
70
+ else { this.swayDown = Math.min(SWAY_RELEASE_FR, this.swayDown + 1); this.swayUp = 0; }
71
+ const up = this.swayUp / SWAY_ATTACK_FR, down = 1 - this.swayDown / SWAY_RELEASE_FR;
72
+ const target = this.vadOn ? up : down;
73
+ this.swayEnv += ENV_FOLLOW_GAIN * (target - this.swayEnv);
74
+ this.swayEnv = Math.min(1, Math.max(0, this.swayEnv));
75
+ const loud = loudnessGain(db) * SWAY_MASTER, env = this.swayEnv;
76
+ this.t += HOP_MS / 1000;
77
+ const w = 2 * Math.PI * this.t, g = loud * env;
78
+ out.push({
79
+ pitch: SWAY_A_PITCH_DEG * rad * g * Math.sin(w * SWAY_F_PITCH + PHASES[0]),
80
+ yaw: SWAY_A_YAW_DEG * rad * g * Math.sin(w * SWAY_F_YAW + PHASES[1]),
81
+ roll: SWAY_A_ROLL_DEG * rad * g * Math.sin(w * SWAY_F_ROLL + PHASES[2]),
82
+ x: SWAY_A_X_MM / 1000 * g * Math.sin(w * SWAY_F_X + PHASES[3]),
83
+ y: SWAY_A_Y_MM / 1000 * g * Math.sin(w * SWAY_F_Y + PHASES[4]),
84
+ z: SWAY_A_Z_MM / 1000 * g * Math.sin(w * SWAY_F_Z + PHASES[5]),
85
+ });
86
+ }
87
+ return out;
88
+ }
89
+ }