import * as THREE from 'three'; import * as xb from 'xrblocks'; import {LipsyncMouth} from 'lipsync'; /** * Puppet sample. Floats a stylised head about a metre in front of the * user and drives its mouth from the local mic. The puppet acts as a * stand-in for what a remote peer would see, since in immersive XR you * can't see your own face. Works in both the desktop simulator and an * immersive WebXR session. * * Tap the on-screen / in-scene mic button to grant audio permission, * then talk to the puppet. Vowels open the mouth, /oo/ rounds it, /ee/ * widens it. */ class LipsyncPuppetSample extends xb.Script { private puppetHead?: THREE.Group; private face?: xb.StylizedFace; private mouth?: LipsyncMouth; private micStream?: MediaStream; private domBtn?: HTMLButtonElement; private spatialBtn?: xb.UIButton; private spatialStatus?: xb.UIText; private started = false; // Scratch vectors for the per-frame face-the-camera lookAt. private readonly camWorld = new THREE.Vector3(); private readonly headWorld = new THREE.Vector3(); override init() { // Stylised puppet head: a sphere face, two eye dots, no body. Uses // a 0.1 m face radius to match netblocks `RemoteUserAvatar` so the // default LipsyncMouth fits naturally. const head = new THREE.Group(); head.position.set(0, xb.user.height, -1); const faceR = 0.1; const faceGeom = new THREE.SphereGeometry(faceR, 32, 24); const faceMat = new THREE.MeshStandardMaterial({ color: 0xf2d4b3, roughness: 0.6, metalness: 0.05, }); const face = new THREE.Mesh(faceGeom, faceMat); head.add(face); const eyeGeom = new THREE.SphereGeometry(faceR * 0.1, 12, 8); const eyeMat = new THREE.MeshBasicMaterial({color: 0x111111}); for (const dx of [-faceR * 0.3, faceR * 0.3]) { const eye = new THREE.Mesh(eyeGeom, eyeMat); // Eyes on the front of the head (local -Z) to match three.js / // WebXR head-forward convention. eye.position.set(dx, faceR * 0.2, -faceR * 0.92); head.add(eye); } this.puppetHead = head; this.add(head); this.add(new THREE.AmbientLight(0xffffff, 0.6)); const key = new THREE.DirectionalLight(0xffffff, 0.9); key.position.set(1, 2, 1); this.add(key); this.buildDomButton(); this.buildSpatialPanel(); } override update() { // Keep the puppet facing the user. The mouth canvas and the eye // spheres both live on the head's local -Z (three.js / WebXR // head-forward convention), so a static puppet would hide them // the moment the user walked or turned around it. Three.js's // Object3D.lookAt orients local +Z at the target, so we look at // the camera mirrored through the head: that puts local -Z (face // side) on the camera. Y is clamped so the puppet only yaws and // doesn't pitch when the user is taller or shorter than it. const head = this.puppetHead; const cam = xb.core?.camera; if (!head || !cam) return; cam.getWorldPosition(this.camWorld); head.getWorldPosition(this.headWorld); const targetX = 2 * this.headWorld.x - this.camWorld.x; const targetZ = 2 * this.headWorld.z - this.camWorld.z; head.lookAt(targetX, this.headWorld.y, targetZ); } private buildDomButton() { const btn = document.createElement('button'); btn.textContent = '๐ŸŽ™๏ธ Start mic'; Object.assign(btn.style, { position: 'fixed', top: '12px', right: '12px', padding: '10px 18px', background: '#9177c7', color: '#fff', border: 'none', borderRadius: '24px', fontSize: '14px', cursor: 'pointer', zIndex: '999', } as Partial); document.body.appendChild(btn); btn.addEventListener('click', () => this.toggleMic()); this.domBtn = btn; } private buildSpatialPanel() { const panel = new xb.UICard({ size: {width: 0.8, height: 0.4}, manipulation: { actions: {translate: {faceCamera: true}}, handle: {action: 'translate'}, }, edge: true, style: {backgroundColor: '#1a1a2add'}, }); const content = new xb.UIPanel({ style: { width: '100%', height: '100%', padding: 28, flexDirection: 'column', gap: 16, }, }); content.add( new xb.UIText({ text: '๐ŸŽ™๏ธ Lipsync puppet', style: {color: '#bfa9ff', fontSize: 34, textAlign: 'center'}, }) ); this.spatialStatus = new xb.UIText({ text: 'mic: off', style: {color: '#7ac0ff', fontSize: 28, textAlign: 'center'}, }); this.spatialBtn = new xb.UIButton({ label: '๐ŸŽ™๏ธ Start mic', onClick: () => this.toggleMic(), style: { height: 76, backgroundColor: '#9177c7', color: '#ffffff', fontSize: 28, borderRadius: 18, }, }); content.add(this.spatialStatus, this.spatialBtn); panel.add(content); panel.position.set(-0.9, xb.user.height + 0.2, -1.0); panel.rotation.y = Math.PI / 8; this.add(panel); } private toggleMic(): Promise | void { return this.started ? this.stopMic() : this.startMic(); } private async startMic() { if (this.started) return; this.started = true; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: {echoCancellation: true, noiseSuppression: true}, video: false, }); this.micStream = stream; // The puppet head already has its own 3D eye spheres, so the // canvas face only draws the mouth (`showEyes: false`). this.face = new xb.StylizedFace({showEyes: false}); this.puppetHead?.add(this.face); this.mouth = new LipsyncMouth(stream, {target: this.face}); this.puppetHead?.add(this.mouth); if (this.domBtn) this.domBtn.textContent = '๐ŸŽ™๏ธ Disable mic'; if (this.spatialBtn) this.spatialBtn.label = '๐ŸŽ™๏ธ Disable mic'; if (this.spatialStatus) this.spatialStatus.text = 'mic: on. talk to the puppet'; } catch (err) { this.started = false; const msg = (err as Error).message; if (this.domBtn) this.domBtn.textContent = `mic failed: ${msg}`; if (this.spatialStatus) this.spatialStatus.text = `mic failed: ${msg}`; } } private stopMic() { if (!this.started) return; this.started = false; if (this.mouth) { this.mouth.parent?.remove(this.mouth); this.mouth = undefined; } if (this.face) { this.face.parent?.remove(this.face); this.face.dispose(); this.face = undefined; } if (this.micStream) { for (const t of this.micStream.getTracks()) t.stop(); this.micStream = undefined; } if (this.domBtn) this.domBtn.textContent = '๐ŸŽ™๏ธ Start mic'; if (this.spatialBtn) this.spatialBtn.label = '๐ŸŽ™๏ธ Start mic'; if (this.spatialStatus) this.spatialStatus.text = 'mic: off'; } } document.addEventListener('DOMContentLoaded', () => { const options = new xb.Options(); options.reticles.enabled = true; options.controllers.visualizeRays = true; options.setAppTitle('Lipsync ยท Puppet'); xb.add(new LipsyncPuppetSample()); xb.init(options); });