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"); } } }