File size: 3,895 Bytes
99e34b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
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"); }
  }
}