This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

typeface / src / main.ts
20 kB 583 lines
1import './style.css'; 2 3import * as THREE from 'three'; 4import { GoogleGenAI, Modality, type LiveServerMessage } from '@google/genai'; 5import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; 6import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; 7import { VRM, VRMExpressionPresetName, VRMLoaderPlugin, VRMUtils } from '@pixiv/three-vrm'; 8 9function requireElement<T>(value: T | null, message: string): T { 10 if (!value) throw new Error(message); 11 return value; 12} 13 14const app = requireElement(document.querySelector<HTMLDivElement>('#app'), 'App root not found'); 15 16app.innerHTML = ` 17 <aside class="panel"> 18 <span class="badge">TypeScript + VRM</span> 19 <h1>Avatar lip sync MVP</h1> 20 <p class="copy">Load a VRM avatar, upload an audio file, and drive mouth movement from the audio amplitude. This is amplitude-based lip sync, so it animates speaking energy rather than phoneme-accurate visemes.</p> 21 <p class="copy" style="margin-top: 8px;">Gemini voice is set to <code>Kore</code>, which should read as a feminine voice.</p> 22 23 <div class="stack"> 24 <section class="card"> 25 <h2>1. Avatar</h2> 26 <p class="copy" style="margin: 0 0 12px; font-size: 0.95rem;">A sample avatar is loaded by default. Upload a different VRM to replace it.</p> 27 <input id="vrmFile" type="file" accept=".vrm,model/gltf-binary" /> 28 </section> 29 30 <section class="card"> 31 <h2>2. Audio</h2> 32 <p class="copy" style="margin: 0 0 12px; font-size: 0.95rem;">A public-domain speech sample is bundled by default. Use the button below or upload your own file.</p> 33 <div style="margin-bottom: 12px;"> 34 <button id="useSampleAudioButton">Use sample speech</button> 35 </div> 36 <input id="audioFile" type="file" accept="audio/*" /> 37 <div style="margin-top: 12px; display: flex; gap: 10px; flex-wrap: wrap;"> 38 <button id="playButton" disabled>Play</button> 39 <button id="stopButton" disabled>Stop</button> 40 </div> 41 <audio id="audio" preload="auto"></audio> 42 </section> 43 44 <section class="card"> 45 <h2>3. Gemini reply</h2> 46 <p class="copy" style="margin: 0 0 12px; font-size: 0.95rem;">Send a text prompt to Gemini Live, receive native audio, and pipe it into the avatar audio player.</p> 47 <textarea id="promptInput" rows="4" style="width: 100%; resize: vertical; border-radius: 10px; border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04); color: inherit; padding: 10px;" placeholder="Say something as the avatar..."></textarea> 48 <div style="margin-top: 12px; display: flex; gap: 10px; flex-wrap: wrap;"> 49 <button id="sendPromptButton">Send to Gemini</button> 50 </div> 51 <p id="geminiStatus" class="footer" style="margin-bottom: 0;">Gemini idle</p> 52 </section> 53 54 <section class="card stats"> 55 <h2>4. Live values</h2> 56 <div class="stat-row"><span>Avatar</span><strong id="avatarStatus">Not loaded</strong></div> 57 <div class="stat-row"><span>Audio</span><strong id="audioStatus">Not loaded</strong></div> 58 <div class="stat-row"><span>Mouth open</span><strong id="mouthValue">0.00</strong></div> 59 <div class="meter"><div id="mouthMeter"></div></div> 60 </section> 61 </div> 62 63 <p class="footer">If your VRM supports expression presets, this app drives <code>aa</code>. Otherwise it falls back to blendshape names like <code>A</code>, <code>aa</code>, or <code>mouthOpen</code>.</p> 64 </aside> 65 <main class="viewport"></main> 66`; 67 68const viewport = requireElement(app.querySelector<HTMLDivElement>('.viewport'), 'Missing viewport'); 69const vrmInput = requireElement(app.querySelector<HTMLInputElement>('#vrmFile'), 'Missing VRM input'); 70const audioInput = requireElement(app.querySelector<HTMLInputElement>('#audioFile'), 'Missing audio input'); 71const useSampleAudioButton = requireElement(app.querySelector<HTMLButtonElement>('#useSampleAudioButton'), 'Missing sample audio button'); 72const promptInput = requireElement(app.querySelector<HTMLTextAreaElement>('#promptInput'), 'Missing prompt input'); 73const sendPromptButton = requireElement(app.querySelector<HTMLButtonElement>('#sendPromptButton'), 'Missing Gemini send button'); 74const geminiStatus = requireElement(app.querySelector<HTMLElement>('#geminiStatus'), 'Missing Gemini status'); 75const playButton = requireElement(app.querySelector<HTMLButtonElement>('#playButton'), 'Missing play button'); 76const stopButton = requireElement(app.querySelector<HTMLButtonElement>('#stopButton'), 'Missing stop button'); 77const audioEl = requireElement(app.querySelector<HTMLAudioElement>('#audio'), 'Missing audio element'); 78const avatarStatus = requireElement(app.querySelector<HTMLElement>('#avatarStatus'), 'Missing avatar status'); 79const audioStatus = requireElement(app.querySelector<HTMLElement>('#audioStatus'), 'Missing audio status'); 80const mouthValue = requireElement(app.querySelector<HTMLElement>('#mouthValue'), 'Missing mouth value'); 81const mouthMeter = requireElement(app.querySelector<HTMLElement>('#mouthMeter'), 'Missing mouth meter'); 82 83const scene = new THREE.Scene(); 84scene.background = new THREE.Color(0x0f172a); 85 86const camera = new THREE.PerspectiveCamera(35, viewport.clientWidth / viewport.clientHeight, 0.1, 100); 87camera.position.set(0, 1.4, 2.2); 88 89const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); 90renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); 91renderer.setSize(viewport.clientWidth, viewport.clientHeight); 92renderer.outputColorSpace = THREE.SRGBColorSpace; 93viewport.appendChild(renderer.domElement); 94 95const controls = new OrbitControls(camera, renderer.domElement); 96controls.target.set(0, 1.2, 0); 97controls.enableDamping = true; 98controls.minDistance = 1; 99controls.maxDistance = 5; 100controls.update(); 101 102scene.add(new THREE.HemisphereLight(0xffffff, 0x1e293b, 1.4)); 103const dirLight = new THREE.DirectionalLight(0xffffff, 1.4); 104dirLight.position.set(1, 1, 1); 105scene.add(dirLight); 106 107const floor = new THREE.Mesh( 108 new THREE.CircleGeometry(3, 64), 109 new THREE.MeshStandardMaterial({ color: 0x111827, roughness: 0.9, metalness: 0.05 }) 110); 111floor.rotation.x = -Math.PI / 2; 112floor.position.y = -0.01; 113scene.add(floor); 114 115const clock = new THREE.Clock(); 116const loader = new GLTFLoader(); 117loader.register((parser) => new VRMLoaderPlugin(parser)); 118 119let currentVrm: VRM | null = null; 120let audioContext: AudioContext | null = null; 121let analyser: AnalyserNode | null = null; 122let sourceNode: MediaElementAudioSourceNode | null = null; 123let dataArray: Uint8Array<ArrayBuffer> | null = null; 124let vrmObjectUrl: string | null = null; 125let audioObjectUrl: string | null = null; 126let mouthOpenAmount = 0; 127let audioEnergy = 0; 128let geminiResponseParts: Uint8Array[] = []; 129 130type PoseBones = { 131 neck?: THREE.Object3D | null; 132 spine?: THREE.Object3D | null; 133 chest?: THREE.Object3D | null; 134 upperChest?: THREE.Object3D | null; 135 leftUpperArm?: THREE.Object3D | null; 136 rightUpperArm?: THREE.Object3D | null; 137 leftLowerArm?: THREE.Object3D | null; 138 rightLowerArm?: THREE.Object3D | null; 139 leftHand?: THREE.Object3D | null; 140 rightHand?: THREE.Object3D | null; 141}; 142 143let poseBones: PoseBones = {}; 144 145const geminiApiKey = import.meta.env.VITE_GEMINI_API_KEY as string | undefined; 146const geminiModel = 'gemini-3.1-flash-live-preview'; 147 148function disposeCurrentVrm() { 149 if (!currentVrm) return; 150 scene.remove(currentVrm.scene); 151 VRMUtils.deepDispose(currentVrm.scene); 152 currentVrm = null; 153 poseBones = {}; 154} 155 156function cachePoseBones(vrm: VRM) { 157 const humanoid = vrm.humanoid; 158 poseBones = { 159 neck: humanoid?.getNormalizedBoneNode('neck'), 160 spine: humanoid?.getNormalizedBoneNode('spine'), 161 chest: humanoid?.getNormalizedBoneNode('chest'), 162 upperChest: humanoid?.getNormalizedBoneNode('upperChest'), 163 leftUpperArm: humanoid?.getNormalizedBoneNode('leftUpperArm'), 164 rightUpperArm: humanoid?.getNormalizedBoneNode('rightUpperArm'), 165 leftLowerArm: humanoid?.getNormalizedBoneNode('leftLowerArm'), 166 rightLowerArm: humanoid?.getNormalizedBoneNode('rightLowerArm'), 167 leftHand: humanoid?.getNormalizedBoneNode('leftHand'), 168 rightHand: humanoid?.getNormalizedBoneNode('rightHand'), 169 }; 170} 171 172function pcm16ToWav(pcmData: Uint8Array, sampleRate = 24000, channels = 1): Blob { 173 const bytesPerSample = 2; 174 const blockAlign = channels * bytesPerSample; 175 const buffer = new ArrayBuffer(44 + pcmData.byteLength); 176 const view = new DataView(buffer); 177 178 const writeString = (offset: number, value: string) => { 179 for (let i = 0; i < value.length; i += 1) view.setUint8(offset + i, value.charCodeAt(i)); 180 }; 181 182 writeString(0, 'RIFF'); 183 view.setUint32(4, 36 + pcmData.byteLength, true); 184 writeString(8, 'WAVE'); 185 writeString(12, 'fmt '); 186 view.setUint32(16, 16, true); 187 view.setUint16(20, 1, true); 188 view.setUint16(22, channels, true); 189 view.setUint32(24, sampleRate, true); 190 view.setUint32(28, sampleRate * blockAlign, true); 191 view.setUint16(32, blockAlign, true); 192 view.setUint16(34, 16, true); 193 writeString(36, 'data'); 194 view.setUint32(40, pcmData.byteLength, true); 195 new Uint8Array(buffer, 44).set(pcmData); 196 return new Blob([buffer], { type: 'audio/wav' }); 197} 198 199function concatUint8Arrays(chunks: Uint8Array[]): Uint8Array { 200 const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); 201 const result = new Uint8Array(total); 202 let offset = 0; 203 for (const chunk of chunks) { 204 result.set(chunk, offset); 205 offset += chunk.byteLength; 206 } 207 return result; 208} 209 210function base64ToUint8Array(base64: string): Uint8Array { 211 const binary = atob(base64); 212 const bytes = new Uint8Array(binary.length); 213 for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); 214 return bytes; 215} 216 217async function playGeneratedSpeech(pcmChunks: Uint8Array[]) { 218 if (audioObjectUrl) URL.revokeObjectURL(audioObjectUrl); 219 const wavBlob = pcm16ToWav(concatUint8Arrays(pcmChunks)); 220 audioObjectUrl = URL.createObjectURL(wavBlob); 221 audioEl.src = audioObjectUrl; 222 audioEl.load(); 223 audioStatus.textContent = 'Gemini response'; 224 playButton.disabled = false; 225 stopButton.disabled = false; 226 ensureAudioGraph(); 227 if (audioContext?.state === 'suspended') await audioContext.resume(); 228 await audioEl.play(); 229} 230 231async function sendPromptToGemini(prompt: string) { 232 if (!geminiApiKey) throw new Error('Missing VITE_GEMINI_API_KEY'); 233 234 geminiStatus.textContent = 'Connecting to Gemini…'; 235 sendPromptButton.disabled = true; 236 geminiResponseParts = []; 237 238 const ai = new GoogleGenAI({ apiKey: geminiApiKey }); 239 const session = await ai.live.connect({ 240 model: geminiModel, 241 config: { 242 responseModalities: [Modality.AUDIO], 243 speechConfig: { 244 voiceConfig: { 245 prebuiltVoiceConfig: { 246 voiceName: 'Kore', 247 }, 248 }, 249 }, 250 systemInstruction: 'You are a concise speaking avatar. Reply conversationally in under 20 seconds.', 251 }, 252 callbacks: { 253 onopen: () => { 254 geminiStatus.textContent = 'Gemini connected'; 255 }, 256 onmessage: async (message: LiveServerMessage) => { 257 const serverContent = message.serverContent; 258 const parts = serverContent?.modelTurn?.parts ?? []; 259 for (const part of parts) { 260 if (part.inlineData?.data) { 261 geminiResponseParts.push(base64ToUint8Array(part.inlineData.data)); 262 } 263 } 264 265 if (serverContent?.turnComplete) { 266 geminiStatus.textContent = 'Playing Gemini audio'; 267 if (geminiResponseParts.length > 0) await playGeneratedSpeech(geminiResponseParts); 268 await session.close(); 269 sendPromptButton.disabled = false; 270 geminiStatus.textContent = 'Gemini idle'; 271 } 272 }, 273 onerror: (error: ErrorEvent) => { 274 console.error(error); 275 geminiStatus.textContent = 'Gemini error'; 276 sendPromptButton.disabled = false; 277 }, 278 onclose: () => { 279 sendPromptButton.disabled = false; 280 }, 281 }, 282 }); 283 284 session.sendClientContent({ 285 turns: [{ role: 'user', parts: [{ text: prompt }] }], 286 turnComplete: true, 287 }); 288} 289 290function setMouth(open: number) { 291 const clamped = THREE.MathUtils.clamp(open, 0, 1); 292 mouthOpenAmount = clamped; 293 mouthValue.textContent = clamped.toFixed(2); 294 mouthMeter.style.width = `${(clamped * 100).toFixed(1)}%`; 295 296 if (!currentVrm) return; 297 298 const manager = currentVrm.expressionManager; 299 if (!manager) return; 300 301 manager.setValue(VRMExpressionPresetName.Aa, clamped); 302 for (const name of ['A', 'a', 'aa', 'Ah', 'mouthOpen']) { 303 try { 304 manager.setValue(name as VRMExpressionPresetName, clamped); 305 } catch { 306 // ignore unsupported expression names 307 } 308 } 309} 310 311function ensureAudioGraph() { 312 if (audioContext && analyser && sourceNode && dataArray) return; 313 314 audioContext = new AudioContext(); 315 analyser = audioContext.createAnalyser(); 316 analyser.fftSize = 2048; 317 analyser.smoothingTimeConstant = 0.82; 318 dataArray = new Uint8Array(new ArrayBuffer(analyser.frequencyBinCount)); 319 sourceNode = audioContext.createMediaElementSource(audioEl); 320 sourceNode.connect(analyser); 321 analyser.connect(audioContext.destination); 322} 323 324function computeMouthOpen() { 325 if (!analyser || !dataArray || audioEl.paused) { 326 audioEnergy = THREE.MathUtils.lerp(audioEnergy, 0, 0.18); 327 setMouth(0); 328 return; 329 } 330 331 analyser.getByteTimeDomainData(dataArray); 332 let sum = 0; 333 for (let i = 0; i < dataArray.length; i += 1) { 334 const centered = (dataArray[i] - 128) / 128; 335 sum += centered * centered; 336 } 337 338 const rms = Math.sqrt(sum / dataArray.length); 339 const boosted = THREE.MathUtils.clamp((rms - 0.012) * 10.0, 0, 1); 340 const exaggerated = THREE.MathUtils.clamp(Math.pow(boosted, 0.7) * 1.25, 0, 1); 341 audioEnergy = THREE.MathUtils.lerp(audioEnergy, exaggerated, 0.35); 342 const expressiveMouth = THREE.MathUtils.clamp(exaggerated * 1.35 + 0.02, 0, 1); 343 setMouth(expressiveMouth); 344} 345 346function animatePresence(elapsed: number) { 347 if (!currentVrm) return; 348 349 const idleSway = Math.sin(elapsed * 1.4) * 0.025; 350 const idleBob = Math.sin(elapsed * 2.1) * 0.012; 351 const talkSway = Math.sin(elapsed * 9.0) * audioEnergy * 0.035; 352 const talkNod = Math.sin(elapsed * 7.2) * audioEnergy * 0.03; 353 const talkLift = audioEnergy * 0.045; 354 355 currentVrm.scene.rotation.y = Math.PI + idleSway + talkSway; 356 currentVrm.scene.rotation.x = talkNod * 0.35; 357 currentVrm.scene.position.y = idleBob + talkLift; 358 359 const { 360 neck, 361 spine, 362 chest, 363 upperChest, 364 leftUpperArm, 365 rightUpperArm, 366 leftLowerArm, 367 rightLowerArm, 368 leftHand, 369 rightHand, 370 } = poseBones; 371 372 if (neck) { 373 neck.rotation.y = talkSway * 0.9; 374 neck.rotation.x = talkNod * 0.8; 375 neck.rotation.z = Math.sin(elapsed * 5.4) * audioEnergy * 0.04; 376 } 377 378 if (spine) { 379 spine.rotation.y = idleSway * 0.45 + talkSway * 0.4; 380 spine.rotation.x = talkNod * 0.2; 381 } 382 383 if (chest) { 384 chest.rotation.x = mouthOpenAmount * 0.05 + audioEnergy * 0.04; 385 chest.rotation.z = Math.sin(elapsed * 3.8) * 0.015 + talkSway * 0.3; 386 } 387 388 if (upperChest) { 389 upperChest.rotation.y = talkSway * 0.35; 390 upperChest.rotation.x = audioEnergy * 0.03; 391 } 392 393 if (leftUpperArm) { 394 leftUpperArm.rotation.z = 1.12 + Math.sin(elapsed * 1.7) * 0.012 + audioEnergy * 0.02; 395 leftUpperArm.rotation.x = 0.03 + Math.sin(elapsed * 2.3) * 0.01; 396 leftUpperArm.rotation.y = -0.04 - talkSway * 0.1; 397 } 398 399 if (rightUpperArm) { 400 rightUpperArm.rotation.z = -1.12 - Math.sin(elapsed * 1.9) * 0.012 - audioEnergy * 0.02; 401 rightUpperArm.rotation.x = 0.03 + Math.sin(elapsed * 2.5) * 0.01; 402 rightUpperArm.rotation.y = 0.04 - talkSway * 0.1; 403 } 404 405 if (leftLowerArm) { 406 leftLowerArm.rotation.z = 0; 407 leftLowerArm.rotation.x = -0.14 + Math.sin(elapsed * 2.8) * 0.008; 408 } 409 410 if (rightLowerArm) { 411 rightLowerArm.rotation.z = 0; 412 rightLowerArm.rotation.x = -0.14 + Math.sin(elapsed * 3.0) * 0.008; 413 } 414 415 if (leftHand) { 416 leftHand.rotation.y = -0.08 + Math.sin(elapsed * 3.5) * 0.02; 417 leftHand.rotation.z = -0.03; 418 } 419 420 if (rightHand) { 421 rightHand.rotation.y = 0.08 - Math.sin(elapsed * 3.3) * 0.02; 422 rightHand.rotation.z = 0.03; 423 } 424} 425 426async function loadVrm(file: File) { 427 disposeCurrentVrm(); 428 if (vrmObjectUrl) URL.revokeObjectURL(vrmObjectUrl); 429 430 vrmObjectUrl = URL.createObjectURL(file); 431 const gltf = await loader.loadAsync(vrmObjectUrl); 432 const vrm = gltf.userData.vrm as VRM | undefined; 433 if (!vrm) throw new Error('Loaded file is not a VRM avatar'); 434 435 VRMUtils.rotateVRM0(vrm); 436 vrm.scene.rotation.y = Math.PI; 437 scene.add(vrm.scene); 438 currentVrm = vrm; 439 cachePoseBones(vrm); 440 avatarStatus.textContent = file.name; 441} 442 443async function loadDefaultVrm() { 444 avatarStatus.textContent = 'Loading sample…'; 445 disposeCurrentVrm(); 446 if (vrmObjectUrl) { 447 URL.revokeObjectURL(vrmObjectUrl); 448 vrmObjectUrl = null; 449 } 450 451 const gltf = await loader.loadAsync('/default-avatar.vrm'); 452 const vrm = gltf.userData.vrm as VRM | undefined; 453 if (!vrm) throw new Error('Default avatar is not a valid VRM'); 454 455 VRMUtils.rotateVRM0(vrm); 456 vrm.scene.rotation.y = Math.PI; 457 scene.add(vrm.scene); 458 currentVrm = vrm; 459 cachePoseBones(vrm); 460 avatarStatus.textContent = 'default-avatar.vrm'; 461} 462 463async function loadAudio(file: File) { 464 if (audioObjectUrl) URL.revokeObjectURL(audioObjectUrl); 465 466 audioObjectUrl = URL.createObjectURL(file); 467 audioEl.src = audioObjectUrl; 468 audioEl.load(); 469 audioStatus.textContent = file.name; 470 playButton.disabled = false; 471 stopButton.disabled = false; 472 setMouth(0); 473} 474 475async function loadSampleAudio() { 476 if (audioObjectUrl) { 477 URL.revokeObjectURL(audioObjectUrl); 478 audioObjectUrl = null; 479 } 480 481 audioEl.src = '/sample-speech.ogg'; 482 audioEl.load(); 483 audioStatus.textContent = 'sample-speech.ogg'; 484 playButton.disabled = false; 485 stopButton.disabled = false; 486 setMouth(0); 487} 488 489vrmInput.addEventListener('change', async () => { 490 const file = vrmInput.files?.[0]; 491 if (!file) return; 492 493 avatarStatus.textContent = 'Loading…'; 494 try { 495 await loadVrm(file); 496 } catch (error) { 497 console.error(error); 498 avatarStatus.textContent = 'Load failed'; 499 } 500}); 501 502audioInput.addEventListener('change', async () => { 503 const file = audioInput.files?.[0]; 504 if (!file) return; 505 506 try { 507 await loadAudio(file); 508 } catch (error) { 509 console.error(error); 510 audioStatus.textContent = 'Load failed'; 511 } 512}); 513 514useSampleAudioButton.addEventListener('click', async () => { 515 try { 516 await loadSampleAudio(); 517 } catch (error) { 518 console.error(error); 519 audioStatus.textContent = 'Sample load failed'; 520 } 521}); 522 523sendPromptButton.addEventListener('click', async () => { 524 const prompt = promptInput.value.trim(); 525 if (!prompt) return; 526 527 try { 528 await sendPromptToGemini(prompt); 529 } catch (error) { 530 console.error(error); 531 geminiStatus.textContent = error instanceof Error ? error.message : 'Gemini request failed'; 532 sendPromptButton.disabled = false; 533 } 534}); 535 536playButton.addEventListener('click', async () => { 537 try { 538 ensureAudioGraph(); 539 if (audioContext?.state === 'suspended') await audioContext.resume(); 540 await audioEl.play(); 541 } catch (error) { 542 console.error(error); 543 } 544}); 545 546stopButton.addEventListener('click', () => { 547 audioEl.pause(); 548 audioEl.currentTime = 0; 549 setMouth(0); 550}); 551 552audioEl.addEventListener('ended', () => setMouth(0)); 553 554window.addEventListener('resize', () => { 555 const width = viewport.clientWidth; 556 const height = viewport.clientHeight; 557 camera.aspect = width / height; 558 camera.updateProjectionMatrix(); 559 renderer.setSize(width, height); 560}); 561 562function tick() { 563 const delta = clock.getDelta(); 564 const elapsed = clock.elapsedTime; 565 currentVrm?.update(delta); 566 computeMouthOpen(); 567 animatePresence(elapsed); 568 controls.update(); 569 renderer.render(scene, camera); 570 requestAnimationFrame(tick); 571} 572 573loadDefaultVrm().catch((error) => { 574 console.error(error); 575 avatarStatus.textContent = 'Sample load failed'; 576}); 577 578loadSampleAudio().catch((error) => { 579 console.error(error); 580 audioStatus.textContent = 'Sample load failed'; 581}); 582 583tick();