| 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 { } |
| 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?.() ?? []; |
| |
| |
| |
| 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"); } |
| } |
| } |
|
|