FormaLau commited on
Commit
da8640b
·
1 Parent(s): 720f857

Synchronize Reachy speech in recordings

Browse files
app/record-sim.mjs CHANGED
@@ -23,6 +23,9 @@ await page.evaluate(() => {
23
  });
24
  await page.evaluate((s) => {
25
  window.rl.chaseCam = false;
 
 
 
26
  window.__gameApi.script.load(s);
27
  window.__gameApi.script.run();
28
  }, script);
@@ -47,17 +50,18 @@ await page.evaluate(async () => {
47
  });
48
  });
49
  await page.waitForTimeout(1200);
50
- const filmB64 = await page.evaluate(() => new Promise((resolve) => {
51
  const recorder = window.__filmRecorder;
52
  recorder.onstop = () => {
53
  const blob = new Blob(window.__filmChunks, { type: 'video/webm' });
54
  const reader = new FileReader();
55
- reader.onload = () => resolve(reader.result.split(',')[1]);
56
  reader.readAsDataURL(blob);
57
  };
58
  recorder.stop();
59
  }));
60
- fs.writeFileSync('reachy-microduck-synced.webm', Buffer.from(filmB64, 'base64'));
 
61
  await context.close();
62
  await browser.close();
63
  console.log('reachy-microduck-synced.webm');
 
23
  });
24
  await page.evaluate((s) => {
25
  window.rl.chaseCam = false;
26
+ window.__speechMarks = [];
27
+ window.__filmStart = performance.now();
28
+ window.addEventListener('reachy-speech-start', (event) => window.__speechMarks.push({ text: event.detail?.text || '', at: (performance.now() - window.__filmStart) / 1000 }));
29
  window.__gameApi.script.load(s);
30
  window.__gameApi.script.run();
31
  }, script);
 
50
  });
51
  });
52
  await page.waitForTimeout(1200);
53
+ const capture = await page.evaluate(() => new Promise((resolve) => {
54
  const recorder = window.__filmRecorder;
55
  recorder.onstop = () => {
56
  const blob = new Blob(window.__filmChunks, { type: 'video/webm' });
57
  const reader = new FileReader();
58
+ reader.onload = () => resolve({ b64: reader.result.split(',')[1], marks: window.__speechMarks });
59
  reader.readAsDataURL(blob);
60
  };
61
  recorder.stop();
62
  }));
63
+ fs.writeFileSync('reachy-microduck-synced.webm', Buffer.from(capture.b64, 'base64'));
64
+ fs.writeFileSync('reachy-microduck-speech-marks.json', JSON.stringify(capture.marks, null, 2));
65
  await context.close();
66
  await browser.close();
67
  console.log('reachy-microduck-synced.webm');
app/src/game/emotions/emotionController.js ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { playMicroduckEmotion } from "../microduck/microduckEmotions.js";
2
+ import { EMOTIONS, loadReachyEmotion } from "./emotions.js";
3
+ import { playUrl } from "../audio.js";
4
+ import { signed } from "../signed.js";
5
+
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 lower, 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 = /daniel|alex|david|mark|arthur|thomas|george|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 = 0.58;
24
+ utterance.rate = 0.82;
25
+ const done = () => { reachy?.setWobbler?.(false); resolve(); };
26
+ utterance.onend = done;
27
+ utterance.onerror = done;
28
+ speechSynthesis.cancel();
29
+ speechSynthesis.speak(utterance);
30
+ });
31
+ }
32
+
33
+ export function createEmotionController({ runtime, reachy, setState }) {
34
+ const active = new Map();
35
+ const runTask = (kind, task, ownsRuntime = false) => {
36
+ if (active.has(kind)) return active.get(kind);
37
+ const promise = (async () => {
38
+ setState({ emotionBusy: true, emotionError: null });
39
+ if (ownsRuntime) runtime.start();
40
+ try { await task(); }
41
+ catch (error) { setState({ emotionError: error?.message || String(error) }); throw error; }
42
+ finally {
43
+ if (ownsRuntime) runtime.finish();
44
+ active.delete(kind);
45
+ setState({ emotionBusy: active.size > 0, emotionStage: active.size ? "active" : null });
46
+ }
47
+ })();
48
+ active.set(kind, promise);
49
+ return promise;
50
+ };
51
+ return {
52
+ get busy() { return !!active; },
53
+ play(emotion) {
54
+ const definition = EMOTIONS[emotion];
55
+ if (!definition) throw new Error(`Unknown emotion: ${emotion}`);
56
+ return runTask("sequence", async () => {
57
+ setState({ emotionStage: "microduck" });
58
+ await playMicroduckEmotion(emotion, runtime);
59
+ setState({ emotionStage: "reachy" });
60
+ await playReachy(emotion, definition);
61
+ }, true);
62
+ },
63
+ playMicroduck(emotion) {
64
+ if (!EMOTIONS[emotion]) return Promise.reject(new Error(`Unknown emotion: ${emotion}`));
65
+ return runTask("microduck", async () => { setState({ emotionStage: "microduck" }); await playMicroduckEmotion(emotion, runtime); }, true);
66
+ },
67
+ playReachy(emotion) {
68
+ const definition = EMOTIONS[emotion];
69
+ if (!definition) return Promise.reject(new Error(`Unknown emotion: ${emotion}`));
70
+ return runTask("reachy", async () => { setState({ emotionStage: "reachy" }); await playReachy(emotion, definition); });
71
+ },
72
+ say: (text) => reachySay(text, reachy),
73
+ };
74
+
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
+ await reachy.playMotion(motion);
79
+ }
80
+ }