microduck-reachy-simulator / src /game /emotions /emotionController.js
laureen
Withdraw Code Training from published main pending fixes
99e34b4
Raw
History Blame Contribute Delete
3.9 kB
import { playMicroduckEmotion } from "../microduck/microduckEmotions.js";
import { EMOTIONS, loadReachyEmotion } from "./emotions.js";
import { playUrl } from "../audio.js";
import { signed } from "../signed.js";
export function reachySay(text, reachy = null) {
try { globalThis.dispatchEvent?.(new CustomEvent("reachy-speech-start", { detail: { text: String(text) } })); } catch { /* recording hook is optional */ }
if (!globalThis.speechSynthesis || !globalThis.SpeechSynthesisUtterance) return Promise.resolve();
reachy?.setWobbler?.(true, "speech");
return new Promise((resolve) => {
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = "en-US";
const voices = speechSynthesis.getVoices?.() ?? [];
// Prefer a stable, traditionally masculine English system voice. Voice
// names vary by browser/OS, so keep a small ordered set of known names
// and fall back to any English voice when none is installed.
const maleHints = /david|mark|guy|daniel|alex|arthur|thomas|george|ryan|google uk english male|microsoft (david|mark|ryan)/i;
const voice = voices.find((candidate) => maleHints.test(candidate.name) && /^en[-_]gb/i.test(candidate.lang))
?? voices.find((candidate) => maleHints.test(candidate.name) && /^en[-_]/i.test(candidate.lang))
?? voices.find((candidate) => /^en[-_]gb/i.test(candidate.lang))
?? voices.find((candidate) => /^en[-_]/i.test(candidate.lang));
if (voice) utterance.voice = voice;
utterance.pitch = 1.12;
utterance.rate = 0.92;
const done = () => { reachy?.setWobbler?.(false, "speech"); resolve(); };
utterance.onend = done;
utterance.onerror = done;
speechSynthesis.cancel();
speechSynthesis.speak(utterance);
});
}
export function createEmotionController({ runtime, reachy, setState }) {
const active = new Map();
const runTask = (kind, task, ownsRuntime = false) => {
if (active.has(kind)) return active.get(kind);
const promise = (async () => {
setState({ emotionBusy: true, emotionError: null });
if (ownsRuntime) runtime.start();
try { await task(); }
catch (error) { setState({ emotionError: error?.message || String(error) }); throw error; }
finally {
if (ownsRuntime) runtime.finish();
active.delete(kind);
setState({ emotionBusy: active.size > 0, emotionStage: active.size ? "active" : null });
}
})();
active.set(kind, promise);
return promise;
};
return {
get busy() { return !!active; },
play(emotion) {
const definition = EMOTIONS[emotion];
if (!definition) throw new Error(`Unknown emotion: ${emotion}`);
return runTask("sequence", async () => {
setState({ emotionStage: "microduck" });
await playMicroduckEmotion(emotion, runtime);
setState({ emotionStage: "reachy" });
await playReachy(emotion, definition);
}, true);
},
playMicroduck(emotion) {
if (!EMOTIONS[emotion]) return Promise.reject(new Error(`Unknown emotion: ${emotion}`));
return runTask("microduck", async () => { setState({ emotionStage: "microduck" }); await playMicroduckEmotion(emotion, runtime); }, true);
},
playReachy(emotion) {
const definition = EMOTIONS[emotion];
if (!definition) return Promise.reject(new Error(`Unknown emotion: ${emotion}`));
return runTask("reachy", async () => { setState({ emotionStage: "reachy" }); await playReachy(emotion, definition); });
},
say: (text) => reachySay(text, reachy),
};
async function playReachy(emotion, definition) {
const motion = await loadReachyEmotion(definition.motion);
playUrl(signed(`./robot/reachy/emotions/${definition.motion}.ogg`), { gain: 0.9 });
reachy.setWobbler(true, "emotion");
try { await reachy.playMotion(motion); }
finally { reachy.setWobbler(false, "emotion"); }
}
}