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
|
| 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(
|
|
|
|
| 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 |
+
}
|