import './style.css'; import * as THREE from 'three'; import { GoogleGenAI, Modality, type LiveServerMessage } from '@google/genai'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { VRM, VRMExpressionPresetName, VRMLoaderPlugin, VRMUtils } from '@pixiv/three-vrm'; function requireElement(value: T | null, message: string): T { if (!value) throw new Error(message); return value; } const app = requireElement(document.querySelector('#app'), 'App root not found'); app.innerHTML = `
`; const viewport = requireElement(app.querySelector('.viewport'), 'Missing viewport'); const vrmInput = requireElement(app.querySelector('#vrmFile'), 'Missing VRM input'); const audioInput = requireElement(app.querySelector('#audioFile'), 'Missing audio input'); const useSampleAudioButton = requireElement(app.querySelector('#useSampleAudioButton'), 'Missing sample audio button'); const promptInput = requireElement(app.querySelector('#promptInput'), 'Missing prompt input'); const sendPromptButton = requireElement(app.querySelector('#sendPromptButton'), 'Missing Gemini send button'); const geminiStatus = requireElement(app.querySelector('#geminiStatus'), 'Missing Gemini status'); const playButton = requireElement(app.querySelector('#playButton'), 'Missing play button'); const stopButton = requireElement(app.querySelector('#stopButton'), 'Missing stop button'); const audioEl = requireElement(app.querySelector('#audio'), 'Missing audio element'); const avatarStatus = requireElement(app.querySelector('#avatarStatus'), 'Missing avatar status'); const audioStatus = requireElement(app.querySelector('#audioStatus'), 'Missing audio status'); const mouthValue = requireElement(app.querySelector('#mouthValue'), 'Missing mouth value'); const mouthMeter = requireElement(app.querySelector('#mouthMeter'), 'Missing mouth meter'); const scene = new THREE.Scene(); scene.background = new THREE.Color(0x0f172a); const camera = new THREE.PerspectiveCamera(35, viewport.clientWidth / viewport.clientHeight, 0.1, 100); camera.position.set(0, 1.4, 2.2); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(viewport.clientWidth, viewport.clientHeight); renderer.outputColorSpace = THREE.SRGBColorSpace; viewport.appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.target.set(0, 1.2, 0); controls.enableDamping = true; controls.minDistance = 1; controls.maxDistance = 5; controls.update(); scene.add(new THREE.HemisphereLight(0xffffff, 0x1e293b, 1.4)); const dirLight = new THREE.DirectionalLight(0xffffff, 1.4); dirLight.position.set(1, 1, 1); scene.add(dirLight); const floor = new THREE.Mesh( new THREE.CircleGeometry(3, 64), new THREE.MeshStandardMaterial({ color: 0x111827, roughness: 0.9, metalness: 0.05 }) ); floor.rotation.x = -Math.PI / 2; floor.position.y = -0.01; scene.add(floor); const clock = new THREE.Clock(); const loader = new GLTFLoader(); loader.register((parser) => new VRMLoaderPlugin(parser)); let currentVrm: VRM | null = null; let audioContext: AudioContext | null = null; let analyser: AnalyserNode | null = null; let sourceNode: MediaElementAudioSourceNode | null = null; let dataArray: Uint8Array | null = null; let vrmObjectUrl: string | null = null; let audioObjectUrl: string | null = null; let mouthOpenAmount = 0; let audioEnergy = 0; let geminiResponseParts: Uint8Array[] = []; type PoseBones = { neck?: THREE.Object3D | null; spine?: THREE.Object3D | null; chest?: THREE.Object3D | null; upperChest?: THREE.Object3D | null; leftUpperArm?: THREE.Object3D | null; rightUpperArm?: THREE.Object3D | null; leftLowerArm?: THREE.Object3D | null; rightLowerArm?: THREE.Object3D | null; leftHand?: THREE.Object3D | null; rightHand?: THREE.Object3D | null; }; let poseBones: PoseBones = {}; const geminiApiKey = import.meta.env.VITE_GEMINI_API_KEY as string | undefined; const geminiModel = 'gemini-3.1-flash-live-preview'; function disposeCurrentVrm() { if (!currentVrm) return; scene.remove(currentVrm.scene); VRMUtils.deepDispose(currentVrm.scene); currentVrm = null; poseBones = {}; } function cachePoseBones(vrm: VRM) { const humanoid = vrm.humanoid; poseBones = { neck: humanoid?.getNormalizedBoneNode('neck'), spine: humanoid?.getNormalizedBoneNode('spine'), chest: humanoid?.getNormalizedBoneNode('chest'), upperChest: humanoid?.getNormalizedBoneNode('upperChest'), leftUpperArm: humanoid?.getNormalizedBoneNode('leftUpperArm'), rightUpperArm: humanoid?.getNormalizedBoneNode('rightUpperArm'), leftLowerArm: humanoid?.getNormalizedBoneNode('leftLowerArm'), rightLowerArm: humanoid?.getNormalizedBoneNode('rightLowerArm'), leftHand: humanoid?.getNormalizedBoneNode('leftHand'), rightHand: humanoid?.getNormalizedBoneNode('rightHand'), }; } function pcm16ToWav(pcmData: Uint8Array, sampleRate = 24000, channels = 1): Blob { const bytesPerSample = 2; const blockAlign = channels * bytesPerSample; const buffer = new ArrayBuffer(44 + pcmData.byteLength); const view = new DataView(buffer); const writeString = (offset: number, value: string) => { for (let i = 0; i < value.length; i += 1) view.setUint8(offset + i, value.charCodeAt(i)); }; writeString(0, 'RIFF'); view.setUint32(4, 36 + pcmData.byteLength, true); writeString(8, 'WAVE'); writeString(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, channels, true); view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * blockAlign, true); view.setUint16(32, blockAlign, true); view.setUint16(34, 16, true); writeString(36, 'data'); view.setUint32(40, pcmData.byteLength, true); new Uint8Array(buffer, 44).set(pcmData); return new Blob([buffer], { type: 'audio/wav' }); } function concatUint8Arrays(chunks: Uint8Array[]): Uint8Array { const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); const result = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.byteLength; } return result; } function base64ToUint8Array(base64: string): Uint8Array { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); return bytes; } async function playGeneratedSpeech(pcmChunks: Uint8Array[]) { if (audioObjectUrl) URL.revokeObjectURL(audioObjectUrl); const wavBlob = pcm16ToWav(concatUint8Arrays(pcmChunks)); audioObjectUrl = URL.createObjectURL(wavBlob); audioEl.src = audioObjectUrl; audioEl.load(); audioStatus.textContent = 'Gemini response'; playButton.disabled = false; stopButton.disabled = false; ensureAudioGraph(); if (audioContext?.state === 'suspended') await audioContext.resume(); await audioEl.play(); } async function sendPromptToGemini(prompt: string) { if (!geminiApiKey) throw new Error('Missing VITE_GEMINI_API_KEY'); geminiStatus.textContent = 'Connecting to Gemini…'; sendPromptButton.disabled = true; geminiResponseParts = []; const ai = new GoogleGenAI({ apiKey: geminiApiKey }); const session = await ai.live.connect({ model: geminiModel, config: { responseModalities: [Modality.AUDIO], speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore', }, }, }, systemInstruction: 'You are a concise speaking avatar. Reply conversationally in under 20 seconds.', }, callbacks: { onopen: () => { geminiStatus.textContent = 'Gemini connected'; }, onmessage: async (message: LiveServerMessage) => { const serverContent = message.serverContent; const parts = serverContent?.modelTurn?.parts ?? []; for (const part of parts) { if (part.inlineData?.data) { geminiResponseParts.push(base64ToUint8Array(part.inlineData.data)); } } if (serverContent?.turnComplete) { geminiStatus.textContent = 'Playing Gemini audio'; if (geminiResponseParts.length > 0) await playGeneratedSpeech(geminiResponseParts); await session.close(); sendPromptButton.disabled = false; geminiStatus.textContent = 'Gemini idle'; } }, onerror: (error: ErrorEvent) => { console.error(error); geminiStatus.textContent = 'Gemini error'; sendPromptButton.disabled = false; }, onclose: () => { sendPromptButton.disabled = false; }, }, }); session.sendClientContent({ turns: [{ role: 'user', parts: [{ text: prompt }] }], turnComplete: true, }); } function setMouth(open: number) { const clamped = THREE.MathUtils.clamp(open, 0, 1); mouthOpenAmount = clamped; mouthValue.textContent = clamped.toFixed(2); mouthMeter.style.width = `${(clamped * 100).toFixed(1)}%`; if (!currentVrm) return; const manager = currentVrm.expressionManager; if (!manager) return; manager.setValue(VRMExpressionPresetName.Aa, clamped); for (const name of ['A', 'a', 'aa', 'Ah', 'mouthOpen']) { try { manager.setValue(name as VRMExpressionPresetName, clamped); } catch { // ignore unsupported expression names } } } function ensureAudioGraph() { if (audioContext && analyser && sourceNode && dataArray) return; audioContext = new AudioContext(); analyser = audioContext.createAnalyser(); analyser.fftSize = 2048; analyser.smoothingTimeConstant = 0.82; dataArray = new Uint8Array(new ArrayBuffer(analyser.frequencyBinCount)); sourceNode = audioContext.createMediaElementSource(audioEl); sourceNode.connect(analyser); analyser.connect(audioContext.destination); } function computeMouthOpen() { if (!analyser || !dataArray || audioEl.paused) { audioEnergy = THREE.MathUtils.lerp(audioEnergy, 0, 0.18); setMouth(0); return; } analyser.getByteTimeDomainData(dataArray); let sum = 0; for (let i = 0; i < dataArray.length; i += 1) { const centered = (dataArray[i] - 128) / 128; sum += centered * centered; } const rms = Math.sqrt(sum / dataArray.length); const boosted = THREE.MathUtils.clamp((rms - 0.012) * 10.0, 0, 1); const exaggerated = THREE.MathUtils.clamp(Math.pow(boosted, 0.7) * 1.25, 0, 1); audioEnergy = THREE.MathUtils.lerp(audioEnergy, exaggerated, 0.35); const expressiveMouth = THREE.MathUtils.clamp(exaggerated * 1.35 + 0.02, 0, 1); setMouth(expressiveMouth); } function animatePresence(elapsed: number) { if (!currentVrm) return; const idleSway = Math.sin(elapsed * 1.4) * 0.025; const idleBob = Math.sin(elapsed * 2.1) * 0.012; const talkSway = Math.sin(elapsed * 9.0) * audioEnergy * 0.035; const talkNod = Math.sin(elapsed * 7.2) * audioEnergy * 0.03; const talkLift = audioEnergy * 0.045; currentVrm.scene.rotation.y = Math.PI + idleSway + talkSway; currentVrm.scene.rotation.x = talkNod * 0.35; currentVrm.scene.position.y = idleBob + talkLift; const { neck, spine, chest, upperChest, leftUpperArm, rightUpperArm, leftLowerArm, rightLowerArm, leftHand, rightHand, } = poseBones; if (neck) { neck.rotation.y = talkSway * 0.9; neck.rotation.x = talkNod * 0.8; neck.rotation.z = Math.sin(elapsed * 5.4) * audioEnergy * 0.04; } if (spine) { spine.rotation.y = idleSway * 0.45 + talkSway * 0.4; spine.rotation.x = talkNod * 0.2; } if (chest) { chest.rotation.x = mouthOpenAmount * 0.05 + audioEnergy * 0.04; chest.rotation.z = Math.sin(elapsed * 3.8) * 0.015 + talkSway * 0.3; } if (upperChest) { upperChest.rotation.y = talkSway * 0.35; upperChest.rotation.x = audioEnergy * 0.03; } if (leftUpperArm) { leftUpperArm.rotation.z = 1.12 + Math.sin(elapsed * 1.7) * 0.012 + audioEnergy * 0.02; leftUpperArm.rotation.x = 0.03 + Math.sin(elapsed * 2.3) * 0.01; leftUpperArm.rotation.y = -0.04 - talkSway * 0.1; } if (rightUpperArm) { rightUpperArm.rotation.z = -1.12 - Math.sin(elapsed * 1.9) * 0.012 - audioEnergy * 0.02; rightUpperArm.rotation.x = 0.03 + Math.sin(elapsed * 2.5) * 0.01; rightUpperArm.rotation.y = 0.04 - talkSway * 0.1; } if (leftLowerArm) { leftLowerArm.rotation.z = 0; leftLowerArm.rotation.x = -0.14 + Math.sin(elapsed * 2.8) * 0.008; } if (rightLowerArm) { rightLowerArm.rotation.z = 0; rightLowerArm.rotation.x = -0.14 + Math.sin(elapsed * 3.0) * 0.008; } if (leftHand) { leftHand.rotation.y = -0.08 + Math.sin(elapsed * 3.5) * 0.02; leftHand.rotation.z = -0.03; } if (rightHand) { rightHand.rotation.y = 0.08 - Math.sin(elapsed * 3.3) * 0.02; rightHand.rotation.z = 0.03; } } async function loadVrm(file: File) { disposeCurrentVrm(); if (vrmObjectUrl) URL.revokeObjectURL(vrmObjectUrl); vrmObjectUrl = URL.createObjectURL(file); const gltf = await loader.loadAsync(vrmObjectUrl); const vrm = gltf.userData.vrm as VRM | undefined; if (!vrm) throw new Error('Loaded file is not a VRM avatar'); VRMUtils.rotateVRM0(vrm); vrm.scene.rotation.y = Math.PI; scene.add(vrm.scene); currentVrm = vrm; cachePoseBones(vrm); avatarStatus.textContent = file.name; } async function loadDefaultVrm() { avatarStatus.textContent = 'Loading sample…'; disposeCurrentVrm(); if (vrmObjectUrl) { URL.revokeObjectURL(vrmObjectUrl); vrmObjectUrl = null; } const gltf = await loader.loadAsync('/default-avatar.vrm'); const vrm = gltf.userData.vrm as VRM | undefined; if (!vrm) throw new Error('Default avatar is not a valid VRM'); VRMUtils.rotateVRM0(vrm); vrm.scene.rotation.y = Math.PI; scene.add(vrm.scene); currentVrm = vrm; cachePoseBones(vrm); avatarStatus.textContent = 'default-avatar.vrm'; } async function loadAudio(file: File) { if (audioObjectUrl) URL.revokeObjectURL(audioObjectUrl); audioObjectUrl = URL.createObjectURL(file); audioEl.src = audioObjectUrl; audioEl.load(); audioStatus.textContent = file.name; playButton.disabled = false; stopButton.disabled = false; setMouth(0); } async function loadSampleAudio() { if (audioObjectUrl) { URL.revokeObjectURL(audioObjectUrl); audioObjectUrl = null; } audioEl.src = '/sample-speech.ogg'; audioEl.load(); audioStatus.textContent = 'sample-speech.ogg'; playButton.disabled = false; stopButton.disabled = false; setMouth(0); } vrmInput.addEventListener('change', async () => { const file = vrmInput.files?.[0]; if (!file) return; avatarStatus.textContent = 'Loading…'; try { await loadVrm(file); } catch (error) { console.error(error); avatarStatus.textContent = 'Load failed'; } }); audioInput.addEventListener('change', async () => { const file = audioInput.files?.[0]; if (!file) return; try { await loadAudio(file); } catch (error) { console.error(error); audioStatus.textContent = 'Load failed'; } }); useSampleAudioButton.addEventListener('click', async () => { try { await loadSampleAudio(); } catch (error) { console.error(error); audioStatus.textContent = 'Sample load failed'; } }); sendPromptButton.addEventListener('click', async () => { const prompt = promptInput.value.trim(); if (!prompt) return; try { await sendPromptToGemini(prompt); } catch (error) { console.error(error); geminiStatus.textContent = error instanceof Error ? error.message : 'Gemini request failed'; sendPromptButton.disabled = false; } }); playButton.addEventListener('click', async () => { try { ensureAudioGraph(); if (audioContext?.state === 'suspended') await audioContext.resume(); await audioEl.play(); } catch (error) { console.error(error); } }); stopButton.addEventListener('click', () => { audioEl.pause(); audioEl.currentTime = 0; setMouth(0); }); audioEl.addEventListener('ended', () => setMouth(0)); window.addEventListener('resize', () => { const width = viewport.clientWidth; const height = viewport.clientHeight; camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height); }); function tick() { const delta = clock.getDelta(); const elapsed = clock.elapsedTime; currentVrm?.update(delta); computeMouthOpen(); animatePresence(elapsed); controls.update(); renderer.render(scene, camera); requestAnimationFrame(tick); } loadDefaultVrm().catch((error) => { console.error(error); avatarStatus.textContent = 'Sample load failed'; }); loadSampleAudio().catch((error) => { console.error(error); audioStatus.textContent = 'Sample load failed'; }); tick();