FormaLau commited on
Commit
3ba9b54
·
1 Parent(s): da8640b

Use consistent childlike Reachy voice and wobbler

Browse files
app/src/game/emotions/emotionController.js CHANGED
@@ -6,23 +6,23 @@ import { signed } from "../signed.js";
6
  export function reachySay(text, reachy = null) {
7
  try { globalThis.dispatchEvent?.(new CustomEvent("reachy-speech-start", { detail: { text: String(text) } })); } catch { /* recording hook is optional */ }
8
  if (!globalThis.speechSynthesis || !globalThis.SpeechSynthesisUtterance) return Promise.resolve();
9
- reachy?.setWobbler?.(true);
10
  return new Promise((resolve) => {
11
  const utterance = new SpeechSynthesisUtterance(text);
12
  utterance.lang = "en-US";
13
  const voices = speechSynthesis.getVoices?.() ?? [];
14
- // Prefer a lower, traditionally masculine English system voice. Voice
15
  // names vary by browser/OS, so keep a small ordered set of known names
16
  // and fall back to any English voice when none is installed.
17
- const maleHints = /daniel|alex|david|mark|arthur|thomas|george|google uk english male|microsoft (david|mark|ryan)/i;
18
  const voice = voices.find((candidate) => maleHints.test(candidate.name) && /^en[-_]gb/i.test(candidate.lang))
19
  ?? voices.find((candidate) => maleHints.test(candidate.name) && /^en[-_]/i.test(candidate.lang))
20
  ?? voices.find((candidate) => /^en[-_]gb/i.test(candidate.lang))
21
  ?? voices.find((candidate) => /^en[-_]/i.test(candidate.lang));
22
  if (voice) utterance.voice = voice;
23
- utterance.pitch = 0.58;
24
- utterance.rate = 0.82;
25
- const done = () => { reachy?.setWobbler?.(false); resolve(); };
26
  utterance.onend = done;
27
  utterance.onerror = done;
28
  speechSynthesis.cancel();
@@ -75,6 +75,8 @@ export function createEmotionController({ runtime, reachy, setState }) {
75
  async function playReachy(emotion, definition) {
76
  const motion = await loadReachyEmotion(definition.motion);
77
  playUrl(signed(`./robot/reachy/emotions/${definition.motion}.ogg`), { gain: 0.9 });
78
- await reachy.playMotion(motion);
 
 
79
  }
80
  }
 
6
  export function reachySay(text, reachy = null) {
7
  try { globalThis.dispatchEvent?.(new CustomEvent("reachy-speech-start", { detail: { text: String(text) } })); } catch { /* recording hook is optional */ }
8
  if (!globalThis.speechSynthesis || !globalThis.SpeechSynthesisUtterance) return Promise.resolve();
9
+ reachy?.setWobbler?.(true, "speech");
10
  return new Promise((resolve) => {
11
  const utterance = new SpeechSynthesisUtterance(text);
12
  utterance.lang = "en-US";
13
  const voices = speechSynthesis.getVoices?.() ?? [];
14
+ // Prefer a stable, traditionally masculine English system voice. Voice
15
  // names vary by browser/OS, so keep a small ordered set of known names
16
  // and fall back to any English voice when none is installed.
17
+ const maleHints = /david|mark|guy|daniel|alex|arthur|thomas|george|ryan|google uk english male|microsoft (david|mark|ryan)/i;
18
  const voice = voices.find((candidate) => maleHints.test(candidate.name) && /^en[-_]gb/i.test(candidate.lang))
19
  ?? voices.find((candidate) => maleHints.test(candidate.name) && /^en[-_]/i.test(candidate.lang))
20
  ?? voices.find((candidate) => /^en[-_]gb/i.test(candidate.lang))
21
  ?? voices.find((candidate) => /^en[-_]/i.test(candidate.lang));
22
  if (voice) utterance.voice = voice;
23
+ utterance.pitch = 1.12;
24
+ utterance.rate = 0.92;
25
+ const done = () => { reachy?.setWobbler?.(false, "speech"); resolve(); };
26
  utterance.onend = done;
27
  utterance.onerror = done;
28
  speechSynthesis.cancel();
 
75
  async function playReachy(emotion, definition) {
76
  const motion = await loadReachyEmotion(definition.motion);
77
  playUrl(signed(`./robot/reachy/emotions/${definition.motion}.ogg`), { gain: 0.9 });
78
+ reachy.setWobbler(true, "emotion");
79
+ try { await reachy.playMotion(motion); }
80
+ finally { reachy.setWobbler(false, "emotion"); }
81
  }
82
  }
app/src/game/reachy/reachyController.js ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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);
8
+ let target = poseToTarget(HOME), motorTargets = [...INITIAL_MOTOR_POSITIONS];
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
15
+ target = next;
16
+ motorTargets = motors;
17
+ }
18
+ function write() {
19
+ const { model, data } = getWorld();
20
+ if (boundModel !== model) {
21
+ addresses = MOTOR_NAMES.map((name) => {
22
+ const id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_ACTUATOR.value, REACHY_PREFIX + name);
23
+ if (id < 0) throw new Error(`Missing Reachy actuator ${name}`);
24
+ return id;
25
+ });
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
+ }
35
+ async function goto(next, duration, signal, playbackSpeed = speed) {
36
+ if (!Number.isFinite(duration) || duration <= 0) throw new Error("Duration must be positive");
37
+ ik(next);
38
+ const from = target;
39
+ const seconds = duration / playbackSpeed;
40
+ await timeline.run({ duration: seconds, signal, update(t) {
41
+ const u = Math.min(1, t / seconds);
42
+ const blend = 10 * u ** 3 - 15 * u ** 4 + 6 * u ** 5;
43
+ apply(interpolateTarget(from, next, blend));
44
+ } });
45
+ }
46
+ async function ownPlayback(task, externalSignal) {
47
+ if (active) throw new Error("Reachy is already playing");
48
+ if (externalSignal?.aborted) throw abortError();
49
+ const playback = new AbortController();
50
+ active = playback;
51
+ const abort = () => playback.abort();
52
+ externalSignal?.addEventListener("abort", abort, { once: true });
53
+ publish({ error: null });
54
+ try { await task(playback.signal); }
55
+ catch (error) { if (error.name !== "AbortError") publish({ error: error.message }); throw error; }
56
+ finally { externalSignal?.removeEventListener("abort", abort); active = null; publish(); }
57
+ }
58
+ const api = {
59
+ write,
60
+ get busy() { return !!active; },
61
+ setWobbler(value, source = "default") {
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(); },
68
+ clear() { active?.abort(); queue = []; publish(); },
69
+ setSpeed(value) {
70
+ if (isLocked()) return;
71
+ if (!Number.isFinite(value) || value < 0.25 || value > 2) throw new Error("Playback speed must be 0.25–2");
72
+ speed = value; publish();
73
+ },
74
+ enqueue(pose, duration, label = "Custom pose") {
75
+ if (isLocked()) return;
76
+ ik(poseToTarget(pose));
77
+ if (!Number.isFinite(duration) || duration <= 0) throw new Error("Duration must be positive");
78
+ queue.push({ pose: { ...pose }, duration, label }); publish();
79
+ },
80
+ playQueue() {
81
+ if (isLocked()) return Promise.reject(new Error("Emotion playback owns the controls"));
82
+ return ownPlayback(async (signal) => {
83
+ while (queue.length) {
84
+ const entry = queue.shift(); publish();
85
+ await goto(poseToTarget(entry.pose), entry.duration, signal);
86
+ }
87
+ });
88
+ },
89
+ playSequence(sequence) {
90
+ if (isLocked()) return Promise.reject(new Error("Emotion playback owns the controls"));
91
+ return ownPlayback(async (signal) => {
92
+ for (const entry of sequence) await goto(poseToTarget(entry.pose), entry.duration, signal);
93
+ });
94
+ },
95
+ // Recorded motion uses original timestamps and quaternion interpolation.
96
+ // Installed by the recorded-move adapter in the emotion phase.
97
+ playMotion(motion, signal) {
98
+ return ownPlayback(async (ownedSignal) => {
99
+ await goto(motion.sample(0), 0.7, ownedSignal, 1);
100
+ await timeline.run({ duration: motion.duration, signal: ownedSignal, update: (t) => apply(motion.sample(t)) });
101
+ }, signal);
102
+ },
103
+ };
104
+ publish();
105
+ return api;
106
+ }