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
|
| 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 = /
|
| 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 =
|
| 24 |
-
utterance.rate = 0.
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
+
}
|