| from __future__ import annotations |
|
|
| import html |
| import os |
| from pathlib import Path |
| from urllib.parse import quote |
|
|
|
|
| def resolve_vrm_path() -> Path: |
| """Return the configured VRM path, preferring the Space's static/Aiko.vrm.""" |
| candidates = [ |
| Path(os.getenv("AIKO_VRM_PATH", "static/Aiko.vrm")), |
| Path("assets/Aiko.vrm"), |
| ] |
| for candidate in candidates: |
| if candidate.exists(): |
| return candidate.resolve() |
| return candidates[0].resolve() |
|
|
|
|
| def gradio_file_urls(path: Path) -> list[str]: |
| """Build Gradio file-serving URL candidates for an allowed local path.""" |
| encoded_path = quote(path.as_posix(), safe="/:") |
| return [f"/gradio_api/file={encoded_path}", f"/file={encoded_path}"] |
|
|
|
|
| def gradio_file_url(path: Path) -> str: |
| """Return the preferred Gradio file-serving URL for compatibility callers.""" |
| return gradio_file_urls(path)[0] |
|
|
|
|
| def avatar_html(vrm_urls: str | list[str]) -> str: |
| """Return an iframe containing the Three/VRM viewer. |
| |
| Camera is framed to a half-body shot (waist-up). The iframe exposes a |
| postMessage API for expression, viseme, ttsText, and duration control. |
| Caption display is handled by the parent chat overlay, not this iframe. |
| |
| postMessage API (JSON string or object): |
| { expression: str, intensity?: float } — set face expression |
| { ttsText: str, duration?: float } — set lip-sync text |
| { speaking: bool } — force speaking state |
| { viseme: str, weight?: float } — direct viseme override |
| """ |
| if isinstance(vrm_urls, str): |
| vrm_urls = [vrm_urls] |
|
|
| srcdoc = rf"""<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> |
| <style> |
| * {{ box-sizing: border-box; }} |
| html, body {{ |
| margin: 0; |
| width: 100%; |
| height: 100%; |
| overflow: hidden; |
| background: radial-gradient(circle at 50% 12%, #22183f 0, #0b0a14 48%, #050509 100%); |
| color: #c8b8e8; |
| font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; |
| }} |
| #wrap {{ |
| position: fixed; |
| inset: 0; |
| width: 100vw !important; |
| height: 100vh !important; |
| max-height: 100vh !important; |
| overflow: hidden !important; |
| }} |
| canvas {{ |
| display: block; |
| width: 100% !important; |
| height: 100% !important; |
| max-width: 100vw !important; |
| max-height: 100vh !important; |
| }} |
| /* HUD: minimal top status only */ |
| #hud {{ |
| position: fixed; |
| inset: 0; |
| pointer-events: none; |
| display: flex; |
| flex-direction: column; |
| justify-content: flex-start; |
| padding: 14px 18px; |
| background: linear-gradient(180deg, rgba(5,5,10,.45), transparent 20%); |
| }} |
| #top {{ display: flex; justify-content: space-between; align-items: center; gap: 12px; }} |
| #status {{ display: flex; align-items: center; gap: 8px; color: #8b7ab6; font-size: 11px; }} |
| #dot {{ width: 8px; height: 8px; border-radius: 999px; background: #4b3a79; box-shadow: 0 0 10px rgba(155,127,212,.2); }} |
| #dot.thinking {{ background: #8ad8ff; box-shadow: 0 0 16px rgba(138,216,255,.8); animation: pulse 1.1s infinite; }} |
| #dot.speaking {{ background: #d68cff; box-shadow: 0 0 16px rgba(214,140,255,.9); animation: pulse .6s infinite; }} |
| #emotion {{ letter-spacing: .16em; text-transform: uppercase; font-size: 11px; color: #7867a3; }} |
| #loader {{ |
| position: fixed; inset: 0; display: grid; place-content: center; gap: 18px; text-align: center; |
| background: #080810; z-index: 10; transition: opacity .45s ease; |
| }} |
| #loader.fade {{ opacity: 0; pointer-events: none; }} |
| #loader h1 {{ margin: 0; color: #b39cff; letter-spacing: .28em; text-transform: uppercase; font-size: 22px; }} |
| #loader p {{ margin: 0; color: #69578f; font-size: 11px; letter-spacing: .16em; }} |
| @keyframes pulse {{ 0%,100% {{ opacity: 1 }} 50% {{ opacity: .45 }} }} |
| </style> |
| <script type="importmap"> |
| {{ |
| "imports": {{ |
| "three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js", |
| "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/", |
| "@pixiv/three-vrm": "https://cdn.jsdelivr.net/npm/@pixiv/three-vrm@3/lib/three-vrm.module.min.js" |
| }} |
| }} |
| </script> |
| </head> |
| <body> |
| <div id="wrap"><canvas id="canvas"></canvas></div> |
| <div id="hud"> |
| <div id="top"> |
| <div id="emotion">neutral</div> |
| <div id="status"><span id="dot"></span><span id="status-text">idle</span></div> |
| </div> |
| </div> |
| <div id="loader"><h1>🌸 Aiko</h1><p id="load-msg">loading VRM…</p></div> |
| <script type="module"> |
| import * as THREE from 'three'; |
| import {{ OrbitControls }} from 'three/addons/controls/OrbitControls.js'; |
| import {{ GLTFLoader }} from 'three/addons/loaders/GLTFLoader.js'; |
| import {{ VRMLoaderPlugin, VRMUtils }} from '@pixiv/three-vrm'; |
| |
| // ── Hard-lock this iframe's document height ────────────────────────────── |
| document.documentElement.style.setProperty('height', '100vh', 'important'); |
| document.documentElement.style.setProperty('overflow', 'hidden', 'important'); |
| document.body.style.setProperty('height', '100vh', 'important'); |
| document.body.style.setProperty('overflow', 'hidden', 'important'); |
| document.body.style.setProperty('max-height', '100vh', 'important'); |
| |
| const RAW_VRM_URLS = {vrm_urls!r}; |
| const VISEME_MAP = {{ A: 'ih', I: 'ih', U: 'ou', E: 'ee', O: 'oh' }}; |
| const VISEME_PRESETS = ['ih', 'ou', 'ee', 'oh']; |
| const TEXT_VISEME_MAP = {{ |
| a: 'ih', á: 'ih', à: 'ih', â: 'ih', ä: 'ih', あ: 'ih', ア: 'ih', か: 'ih', カ: 'ih', さ: 'ih', サ: 'ih', た: 'ih', タ: 'ih', な: 'ih', ナ: 'ih', は: 'ih', ハ: 'ih', ま: 'ih', マ: 'ih', や: 'ih', ヤ: 'ih', ら: 'ih', ラ: 'ih', わ: 'ih', ワ: 'ih', |
| i: 'ih', í: 'ih', ì: 'ih', î: 'ih', ï: 'ih', y: 'ih', い: 'ih', イ: 'ih', き: 'ih', キ: 'ih', し: 'ih', シ: 'ih', ち: 'ih', チ: 'ih', に: 'ih', ニ: 'ih', ひ: 'ih', ヒ: 'ih', み: 'ih', ミ: 'ih', り: 'ih', リ: 'ih', |
| u: 'ou', ú: 'ou', ù: 'ou', û: 'ou', ü: 'ou', う: 'ou', ウ: 'ou', く: 'ou', ク: 'ou', す: 'ou', ス: 'ou', つ: 'ou', ツ: 'ou', ぬ: 'ou', ヌ: 'ou', ふ: 'ou', フ: 'ou', む: 'ou', ム: 'ou', ゆ: 'ou', ユ: 'ou', る: 'ou', ル: 'ou', |
| e: 'ee', é: 'ee', è: 'ee', ê: 'ee', ë: 'ee', え: 'ee', エ: 'ee', け: 'ee', ケ: 'ee', せ: 'ee', セ: 'ee', て: 'ee', テ: 'ee', ね: 'ee', ネ: 'ee', へ: 'ee', ヘ: 'ee', め: 'ee', メ: 'ee', れ: 'ee', レ: 'ee', |
| o: 'oh', ó: 'oh', ò: 'oh', ô: 'oh', ö: 'oh', お: 'oh', オ: 'oh', こ: 'oh', コ: 'oh', そ: 'oh', ソ: 'oh', と: 'oh', ト: 'oh', の: 'oh', ノ: 'oh', ほ: 'oh', ホ: 'oh', も: 'oh', モ: 'oh', よ: 'oh', ヨ: 'oh', ろ: 'oh', ロ: 'oh', を: 'oh', ヲ: 'oh', ん: 'oh', ン: 'oh', |
| }}; |
| |
| function withTrailingSlash(url) {{ return url.endsWith('/') ? url : url + '/'; }} |
| function buildVrmUrls(rawUrls) {{ |
| const urls = []; |
| let parentHref = null, parentOrigin = null; |
| try {{ parentHref = parent.location.href; parentOrigin = parent.location.origin; }} |
| catch (_) {{ parentHref = window.location.href; parentOrigin = window.location.origin; }} |
| for (const raw of rawUrls) {{ |
| if (!raw) continue; |
| try {{ |
| if (/^https?:\/\//.test(raw)) {{ urls.push(raw); }} |
| else {{ |
| urls.push(new URL(raw, parentOrigin).href); |
| urls.push(new URL(raw.replace(/^\//, ''), withTrailingSlash(parentHref)).href); |
| }} |
| }} catch (err) {{ console.warn('[aiko-vrm] bad VRM URL', raw, err); }} |
| }} |
| return [...new Set(urls)]; |
| }} |
| |
| const VRM_URLS = buildVrmUrls(RAW_VRM_URLS); |
| const canvas = document.getElementById('canvas'); |
| const renderer = new THREE.WebGLRenderer({{ canvas, antialias: true, alpha: true }}); |
| renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); |
| renderer.outputColorSpace = THREE.SRGBColorSpace; |
| |
| const scene = new THREE.Scene(); |
| const camera = new THREE.PerspectiveCamera(22, 1, 0.1, 100); |
| camera.position.set(0.40, 1.18, 2.5); |
| const controls = new OrbitControls(camera, canvas); |
| controls.target.set(0.40, 1.15, 0); |
| controls.enableDamping = true; |
| controls.enablePan = false; |
| controls.minDistance = 1.0; |
| controls.maxDistance = 3.2; |
| |
| scene.add(new THREE.HemisphereLight(0xded4ff, 0x21182f, 2.4)); |
| const key = new THREE.DirectionalLight(0xffffff, 2.7); |
| key.position.set(1.8, 3.0, 2.5); |
| scene.add(key); |
| const rim = new THREE.DirectionalLight(0x9b7cff, 1.5); |
| rim.position.set(-2.5, 1.4, -1.2); |
| scene.add(rim); |
| |
| let vrm = null; |
| let mouth = 0; |
| let smoothedAudioMouth = 0; |
| let speaking = false; |
| const clock = new THREE.Clock(); |
| let blinkTimer = 3.0 + Math.random() * 4.0; |
| let blinkPhase = 'wait'; |
| let blinkT = 0; |
| const BLINK_CLOSE_DUR = 0.07; |
| const BLINK_OPEN_DUR = 0.10; |
| let exprResetTimer = null; |
| const EXPR_RESET_DELAY = 4000; |
| let persistentEmotion = null; |
| let idleTime = 0; |
| let speechText = ''; |
| let speechVisemes = []; |
| let speechStartedAt = 0; |
| let speechDuration = 0; |
| |
| const REST = window._REST = {{ |
| leftUpperArm: {{ x: 0.02, y: 0.0, z: -1.28 }}, |
| rightUpperArm: {{ x: 0.02, y: 0.0, z: 1.28 }}, |
| leftLowerArm: {{ x: -0.12, y: 0.0, z: -0.08 }}, |
| rightLowerArm: {{ x: -0.12, y: 0.0, z: 0.08 }}, |
| leftHand: {{ x: 0.0, y: 0.08, z: 0.0 }}, |
| rightHand: {{ x: 0.0, y:-0.08, z: 0.0 }}, |
| }}; |
| |
| const dot = document.getElementById('dot'); |
| const statusText = document.getElementById('status-text'); |
| const emotionEl = document.getElementById('emotion'); |
| |
| // ───────────────────────────────────────────────────────────────────────── |
| function nextBlinkWait() {{ return 3.0 + Math.random() * 4.0; }} |
| function expressionNames() {{ |
| return (vrm?.expressionManager?.expressions ?? []) |
| .map(e => e.expressionName ?? e.name).filter(Boolean); |
| }} |
| function hasExpression(name) {{ return expressionNames().includes(name); }} |
| function safeSetExpression(name, weight) {{ |
| try {{ vrm?.expressionManager?.setValue(name, weight); }} catch (_) {{}} |
| }} |
| |
| let lastW = 0, lastH = 0; |
| function resize() {{ |
| const w = Math.max(1, Math.min(window.screen.width, canvas.clientWidth || window.innerWidth)); |
| const h = Math.max(1, Math.min(window.screen.height, canvas.clientHeight || window.innerHeight)); |
| if (w === lastW && h === lastH) return; |
| lastW = w; lastH = h; |
| renderer.setSize(w, h, false); |
| camera.aspect = w / h; |
| camera.updateProjectionMatrix(); |
| }} |
| addEventListener('resize', resize); |
| |
| function setExpression(name, weight = 1, persistent = false) {{ |
| if (!vrm?.expressionManager) return; |
| for (const k of ['happy', 'relaxed', 'angry', 'sad', 'surprised']) {{ |
| safeSetExpression(k, k === name ? weight : 0); |
| }} |
| emotionEl.textContent = name || 'neutral'; |
| if (persistent) persistentEmotion = name; |
| }} |
| |
| function setMouth(weight, viseme = 'ih') {{ |
| if (!vrm) return; |
| const clamped = Math.max(0, Math.min(1, Number(weight) || 0)); |
| let preset = VISEME_MAP[viseme] ?? viseme ?? 'ih'; |
| if (preset === 'aa') preset = 'ih'; // Disable 'aa' completely due to chin artifact |
| let usedExpression = false; |
| if (vrm.expressionManager) {{ |
| for (const k of VISEME_PRESETS) {{ |
| if (hasExpression(k)) {{ |
| vrm.expressionManager.setValue(k, k === preset ? clamped : 0); |
| usedExpression = true; |
| }} |
| }} |
| try {{ |
| const em = vrm.expressionManager; |
| if (em._expressions) {{ |
| em._expressions.forEach(expr => expr.applyWeight({{ multiplier: 1 }})); |
| }} |
| }} catch (_) {{}} |
| }} |
| if (!usedExpression) {{ |
| const jaw = vrm.humanoid?.getNormalizedBoneNode?.('jaw') || vrm.humanoid?.getRawBoneNode?.('jaw'); |
| if (jaw) jaw.rotation.x = clamped * 0.5; |
| }} |
| }} |
| |
| function clearMouth() {{ setMouth(0, 'ih'); }} |
| |
| function setStatus(mode) {{ |
| const nextMode = String(mode || 'idle').toLowerCase(); |
| if (nextMode === 'idle' && performance.now() < speakingLockUntil) return; |
| if (nextMode === 'speaking') {{ |
| speaking = true; |
| dot.className = 'speaking'; |
| statusText.textContent = 'speaking'; |
| if (persistentEmotion && persistentEmotion !== 'neutral') {{ |
| setExpression(persistentEmotion, 1.0, true); |
| }} |
| return; |
| }} |
| speaking = false; |
| dot.className = nextMode === 'thinking' ? 'thinking' : ''; |
| statusText.textContent = nextMode === 'thinking' ? 'thinking' : 'idle'; |
| clearMouth(); |
| if (nextMode === 'thinking') {{ |
| clearTimeout(exprResetTimer); |
| if (persistentEmotion && persistentEmotion !== 'neutral') {{ |
| setExpression(persistentEmotion, 1.0, true); |
| }} |
| }} else {{ |
| persistentEmotion = null; |
| setExpression('relaxed', 0.25); |
| }} |
| }} |
| let speakingLockUntil = 0; |
| |
| function setSpeaking(active) {{ |
| if (!active && performance.now() < speakingLockUntil) return; |
| setStatus(active ? 'speaking' : 'idle'); |
| }} |
| |
| function estimateSpeechDuration(text, requestedDuration = null) {{ |
| const explicit = Number(requestedDuration); |
| if (Number.isFinite(explicit) && explicit > 0) return explicit; |
| if (lastAudio && Number.isFinite(lastAudio.duration) && lastAudio.duration > 0) return lastAudio.duration; |
| return Math.max(1.0, Math.min(12, text.length * 0.10)); |
| }} |
| |
| function textToVisemes(text) {{ |
| const tokens = []; |
| let lastViseme = 'ih'; |
| for (const rawChar of String(text || '').toLowerCase()) {{ |
| const viseme = TEXT_VISEME_MAP[rawChar]; |
| if (viseme) {{ |
| lastViseme = viseme; |
| tokens.push({{ viseme, weight: 0.88 }}); |
| }} else if (/[,.;:!?。!?、\s]/.test(rawChar)) {{ |
| tokens.push({{ viseme: lastViseme, weight: 0.08 }}); |
| }} else if (/[bcdfghjklmnpqrstvwxz]/.test(rawChar)) {{ |
| tokens.push({{ viseme: lastViseme, weight: 0.28 }}); |
| }} |
| }} |
| return tokens.length ? tokens : [{{ viseme: 'ih', weight: 0.25 }}]; |
| }} |
| |
| function setSpeechText(text, duration = null) {{ |
| const nextText = String(text || '').trim(); |
| if (!nextText) return; |
| speechText = nextText; |
| speechVisemes = textToVisemes(speechText); |
| speechDuration = estimateSpeechDuration(speechText, duration); |
| speechStartedAt = performance.now(); |
| // Caption display is handled by the parent page's chat overlay — |
| // no caption bar in this iframe. |
| }} |
| |
| function currentTextMouth(now) {{ |
| if (!speechVisemes.length) return null; |
| const elapsed = lastAudio && !lastAudio.paused |
| ? lastAudio.currentTime |
| : (now - speechStartedAt) / 1000; |
| |
| // Realistic syllable rate independent of text length |
| const syllablesPerSecond = 4.0; |
| const syllablePhase = Math.sin(elapsed * syllablesPerSecond * Math.PI); |
| |
| const audioDuration = lastAudio && Number.isFinite(lastAudio.duration) && lastAudio.duration > 0 |
| ? lastAudio.duration : speechDuration; |
| const duration = Math.max(0.25, audioDuration || speechDuration || 1); |
| const progress = Math.max(0, Math.min(0.999, elapsed / duration)); |
| const index = Math.min(speechVisemes.length - 1, Math.floor(progress * speechVisemes.length)); |
| const token = speechVisemes[index]; |
| |
| return {{ |
| viseme: token.viseme, |
| weight: Math.max(0, Math.min(1, token.weight * (0.55 + Math.abs(syllablePhase) * 0.45))), |
| }}; |
| }} |
| |
| function getBone(name) {{ |
| const h = vrm?.humanoid; |
| if (!h) return null; |
| return h.getRawBoneNode?.(name) || h.getNormalizedBoneNode?.(name) || null; |
| }} |
| |
| function applyIdle(dt) {{ |
| if (!vrm?.humanoid) return; |
| idleTime += dt; |
| const breath = Math.sin(idleTime * 0.83) * 0.013; |
| const chest = getBone('chest'); |
| const spine = getBone('spine'); |
| if (chest) chest.rotation.x = breath; |
| if (spine) spine.rotation.x = breath * 0.5; |
| const hips = getBone('hips'); |
| if (hips) {{ |
| hips.rotation.z = Math.sin(idleTime * 0.41) * 0.012; |
| hips.rotation.x = Math.sin(idleTime * 0.67) * 0.008; |
| hips.position.x = Math.sin(idleTime * 0.41) * 0.003; |
| }} |
| const head = getBone('head'); |
| if (head) {{ |
| head.rotation.y = Math.sin(idleTime * 0.31) * 0.055 + Math.sin(idleTime * 1.13) * 0.012; |
| head.rotation.z = Math.sin(idleTime * 0.27 + 1.1) * 0.018 + Math.sin(idleTime * 0.71) * 0.006; |
| head.rotation.x = Math.sin(idleTime * 0.53) * 0.012; |
| }} |
| if (!speaking) {{ |
| safeSetExpression('relaxed', 0.20 + Math.sin(idleTime * 0.37) * 0.035); |
| safeSetExpression('happy', Math.max(0, Math.sin(idleTime * 0.19 - 0.8)) * 0.035); |
| }} |
| const neck = getBone('neck'); |
| if (neck && head) {{ |
| neck.rotation.y = head.rotation.y * 0.3; |
| neck.rotation.z = head.rotation.z * 0.3; |
| }} |
| const lUA = getBone('leftUpperArm'), rUA = getBone('rightUpperArm'); |
| const lLA = getBone('leftLowerArm'), rLA = getBone('rightLowerArm'); |
| const lH = getBone('leftHand'), rH = getBone('rightHand'); |
| if (lUA) {{ lUA.rotation.x = REST.leftUpperArm.x + Math.sin(idleTime * 0.47) * 0.010; lUA.rotation.y = REST.leftUpperArm.y + Math.sin(idleTime * 0.33) * 0.006; lUA.rotation.z = REST.leftUpperArm.z + Math.sin(idleTime * 0.41) * 0.008; }} |
| if (rUA) {{ rUA.rotation.x = REST.rightUpperArm.x + Math.sin(idleTime * 0.53 + 0.9) * 0.010; rUA.rotation.y = REST.rightUpperArm.y + Math.sin(idleTime * 0.35 + 0.4) * 0.006; rUA.rotation.z = REST.rightUpperArm.z + Math.sin(idleTime * 0.37 + 0.7) * 0.008; }} |
| if (lLA) {{ lLA.rotation.x = REST.leftLowerArm.x + Math.sin(idleTime * 0.61) * 0.008; lLA.rotation.y = REST.leftLowerArm.y; lLA.rotation.z = REST.leftLowerArm.z + Math.sin(idleTime * 0.43) * 0.004; }} |
| if (rLA) {{ rLA.rotation.x = REST.rightLowerArm.x + Math.sin(idleTime * 0.57 + 1.4) * 0.008; rLA.rotation.y = REST.rightLowerArm.y; rLA.rotation.z = REST.rightLowerArm.z + Math.sin(idleTime * 0.51 + 0.5) * 0.004; }} |
| if (lH) {{ lH.rotation.x = REST.leftHand.x; lH.rotation.y = REST.leftHand.y + Math.sin(idleTime * 0.33) * 0.008; lH.rotation.z = REST.leftHand.z; }} |
| if (rH) {{ rH.rotation.x = REST.rightHand.x; rH.rotation.y = REST.rightHand.y + Math.sin(idleTime * 0.29 + 1.2) * 0.008; rH.rotation.z = REST.rightHand.z; }} |
| }} |
| |
| // ── Idle gesture state machine ──────────────────────────────────────────── |
| let gestureState = 'none'; |
| let gestureT = 0; |
| let gestureDuration = 0; |
| let gestureCooldown = 4 + Math.random() * 6; |
| let gestureTarget = null; |
| const GESTURES = ['lookAround', 'sideGlance', 'meetGaze', 'curiousTilt', 'shiftWeight', 'hairTuck', 'stretchNeck']; |
| const GESTURE_DURATION = {{ |
| lookAround: 3.2, sideGlance: 2.2, meetGaze: 2.8, |
| curiousTilt: 2.4, shiftWeight: 3.2, hairTuck: 2.7, stretchNeck: 2.6, |
| }}; |
| |
| function pickGesture() {{ |
| const g = GESTURES[Math.floor(Math.random() * GESTURES.length)]; |
| gestureState = g; |
| gestureT = 0; |
| gestureDuration = GESTURE_DURATION[g] ?? 2.5; |
| const side = Math.random() < 0.5 ? -1 : 1; |
| gestureTarget = {{ |
| lookAround: side * (0.22 + Math.random() * 0.18), |
| sideGlance: side * (0.16 + Math.random() * 0.10), |
| curiousTilt: side * (0.08 + Math.random() * 0.07), |
| hairSide: side, |
| }}; |
| }} |
| |
| function easeInOutSine(t) {{ return -(Math.cos(Math.PI * t) - 1) / 2; }} |
| function holdCurve(progress, inPortion = 0.28, outPortion = 0.30) {{ |
| if (progress < inPortion) return easeInOutSine(progress / inPortion); |
| if (progress > 1 - outPortion) return easeInOutSine((1 - progress) / outPortion); |
| return 1; |
| }} |
| |
| function applyGestures(dt) {{ |
| if (!vrm?.humanoid) return; |
| if (speaking) {{ gestureState = 'none'; return; }} |
| if (gestureState === 'none') {{ |
| gestureCooldown -= dt; |
| if (gestureCooldown <= 0) {{ pickGesture(); gestureCooldown = 4 + Math.random() * 6; }} |
| return; |
| }} |
| gestureT += dt; |
| const progress = Math.min(1, gestureT / gestureDuration); |
| const intensity = Math.sin(progress * Math.PI); |
| const eased = easeInOutSine(progress); |
| const held = holdCurve(progress); |
| const head = getBone('head'), neck = getBone('neck'); |
| const lUA = getBone('leftUpperArm'), lLA = getBone('leftLowerArm'), lH = getBone('leftHand'); |
| const rUA = getBone('rightUpperArm'), rLA = getBone('rightLowerArm'), rH = getBone('rightHand'); |
| const spine = getBone('spine'), hips = getBone('hips'); |
| switch (gestureState) {{ |
| case 'lookAround': |
| if (head) {{ head.rotation.y += gestureTarget.lookAround * held; head.rotation.x += Math.sin(eased * Math.PI) * 0.025; }} |
| if (neck) neck.rotation.y += gestureTarget.lookAround * 0.22 * held; |
| break; |
| case 'sideGlance': |
| if (head) {{ head.rotation.y += gestureTarget.sideGlance * held; head.rotation.z -= gestureTarget.sideGlance * 0.18 * intensity; }} |
| safeSetExpression('relaxed', 0.28); |
| break; |
| case 'meetGaze': |
| if (head) {{ head.rotation.y *= 1 - held * 0.78; head.rotation.z *= 1 - held * 0.70; head.rotation.x += held * 0.018; }} |
| if (neck) {{ neck.rotation.y *= 1 - held * 0.55; neck.rotation.z *= 1 - held * 0.55; }} |
| safeSetExpression('relaxed', 0.30); |
| safeSetExpression('happy', 0.04 * held); |
| break; |
| case 'curiousTilt': |
| if (head) {{ head.rotation.z += gestureTarget.curiousTilt * held; head.rotation.x -= 0.018 * intensity; }} |
| if (neck) neck.rotation.z += gestureTarget.curiousTilt * 0.45 * held; |
| break; |
| case 'shiftWeight': |
| if (hips) hips.position.x += Math.sin(eased * Math.PI) * 0.016; |
| if (spine) spine.rotation.z += Math.sin(eased * Math.PI) * 0.018; |
| if (head) head.rotation.z -= Math.sin(eased * Math.PI) * 0.012; |
| break; |
| case 'hairTuck': |
| if (gestureTarget.hairSide < 0) {{ |
| if (lUA) {{ lUA.rotation.z = REST.leftUpperArm.z + intensity * 0.34; lUA.rotation.x = REST.leftUpperArm.x - intensity * 0.12; }} |
| if (lLA) {{ lLA.rotation.x = REST.leftLowerArm.x - intensity * 0.26; lLA.rotation.z = REST.leftLowerArm.z - intensity * 0.10; }} |
| if (lH) {{ lH.rotation.y = REST.leftHand.y + intensity * 0.16; lH.rotation.z = REST.leftHand.z - intensity * 0.10; }} |
| }} else {{ |
| if (rUA) {{ rUA.rotation.z = REST.rightUpperArm.z - intensity * 0.34; rUA.rotation.x = REST.rightUpperArm.x - intensity * 0.12; }} |
| if (rLA) {{ rLA.rotation.x = REST.rightLowerArm.x - intensity * 0.26; rLA.rotation.z = REST.rightLowerArm.z + intensity * 0.10; }} |
| if (rH) {{ rH.rotation.y = REST.rightHand.y - intensity * 0.16; rH.rotation.z = REST.rightHand.z + intensity * 0.10; }} |
| }} |
| if (head) {{ head.rotation.z -= gestureTarget.hairSide * intensity * 0.035; head.rotation.y += gestureTarget.hairSide * intensity * 0.025; }} |
| break; |
| case 'stretchNeck': |
| if (neck) neck.rotation.x += -intensity * 0.05; |
| if (head) head.rotation.x += -intensity * 0.04; |
| break; |
| }} |
| if (progress >= 1) gestureState = 'none'; |
| }} |
| |
| function applyBlink(dt) {{ |
| if (!vrm?.expressionManager) return; |
| if (blinkPhase === 'wait') {{ |
| blinkTimer -= dt; |
| if (blinkTimer <= 0) {{ blinkPhase = 'closing'; blinkT = 0; }} |
| }} else if (blinkPhase === 'closing') {{ |
| blinkT += dt; |
| safeSetExpression('blink', Math.min(blinkT / BLINK_CLOSE_DUR, 1)); |
| if (blinkT >= BLINK_CLOSE_DUR) {{ blinkPhase = 'opening'; blinkT = 0; }} |
| }} else if (blinkPhase === 'opening') {{ |
| blinkT += dt; |
| safeSetExpression('blink', 1 - Math.min(blinkT / BLINK_OPEN_DUR, 1)); |
| if (blinkT >= BLINK_OPEN_DUR) {{ |
| blinkPhase = 'wait'; blinkTimer = nextBlinkWait(); |
| safeSetExpression('blink', 0); |
| }} |
| }} |
| }} |
| |
| let lastAudio = null; |
| let audioContext = null; |
| let analyserAudio = null; |
| let audioData = null; |
| let audioSource = null; |
| let audioMeterOk = false; |
| let meteredAudio = null; |
| let meteredSrc = null; |
| |
| function setupAudioMeter(audio) {{ |
| if (!audio) return; |
| const currentSrc = audio.currentSrc || audio.src || ''; |
| if (audioMeterOk && audio === meteredAudio && currentSrc === meteredSrc) return; |
| |
| if (audio !== meteredAudio) {{ |
| try {{ if (analyserAudio) analyserAudio.disconnect(); }} catch (_) {{}} |
| try {{ if (audioSource && audioSource !== audio._aikoMediaSource) audioSource.disconnect(); }} catch (_) {{}} |
| audioMeterOk = false; |
| analyserAudio = null; |
| audioData = null; |
| audioSource = null; |
| }} else if (currentSrc !== meteredSrc) {{ |
| try {{ if (analyserAudio) analyserAudio.disconnect(); }} catch (_) {{}} |
| audioMeterOk = false; |
| }} |
| |
| try {{ |
| audioContext = audioContext || new (window.AudioContext || window.webkitAudioContext)(); |
| if (audioContext.state === 'suspended') audioContext.resume().catch(() => {{}}); |
| analyserAudio = audioContext.createAnalyser(); |
| analyserAudio.fftSize = 512; |
| analyserAudio.smoothingTimeConstant = 0.72; |
| audioData = new Uint8Array(analyserAudio.fftSize); |
| audioSource = audio._aikoMediaSource || audioContext.createMediaElementSource(audio); |
| audio._aikoMediaSource = audioSource; |
| audioSource.connect(analyserAudio); |
| analyserAudio.connect(audioContext.destination); |
| audioMeterOk = true; |
| meteredAudio = audio; |
| meteredSrc = currentSrc; |
| smoothedAudioMouth = 0; |
| }} catch (err) {{ |
| console.warn('[aiko-vrm] audio meter unavailable; using text lip-sync fallback', err); |
| audioMeterOk = false; |
| meteredAudio = null; |
| meteredSrc = null; |
| analyserAudio = null; |
| audioData = null; |
| }} |
| }} |
| |
| function getAudioMouth() {{ |
| if (!analyserAudio || !audioData) return null; |
| analyserAudio.getByteTimeDomainData(audioData); |
| let sum = 0; |
| for (let i = 0; i < audioData.length; i++) {{ |
| const centered = (audioData[i] - 128) / 128; |
| sum += centered * centered; |
| }} |
| const rms = Math.sqrt(sum / audioData.length); |
| const gated = Math.max(0, rms - 0.018); |
| const target = Math.min(1, Math.pow(gated * 7.5, 0.72)); |
| smoothedAudioMouth += (target - smoothedAudioMouth) * (target > smoothedAudioMouth ? 0.25 : 0.15); |
| return smoothedAudioMouth; |
| }} |
| |
| function findParentAudio() {{ |
| try {{ return parent.document.querySelector('#aiko-audio audio') || parent.document.querySelector('audio'); }} |
| catch (_) {{ return null; }} |
| }} |
| |
| function syncAudioState(audio) {{ |
| if (!audio) return; |
| const isPlaying = !audio.paused && !audio.ended && audio.currentTime >= 0; |
| if (isPlaying) {{ |
| speakingLockUntil = performance.now() + 800; |
| setSpeaking(true); |
| setupAudioMeter(audio); |
| }} |
| }} |
| |
| let pauseDebounce = null; |
| function attachAudio(audio) {{ |
| if (!audio) return; |
| if (audio !== lastAudio) {{ |
| if (lastAudio) {{ |
| audioMeterOk = false; |
| meteredAudio = null; |
| meteredSrc = null; |
| }} |
| lastAudio = audio; |
| |
| audio.addEventListener('play', () => {{ |
| clearTimeout(pauseDebounce); |
| speakingLockUntil = performance.now() + 800; |
| setSpeaking(true); |
| setupAudioMeter(audio); |
| if (window._aikoLatestTtsText) {{ |
| setSpeechText(window._aikoLatestTtsText, audio.duration); |
| window._aikoLatestTtsText = ''; |
| }} |
| }}); |
| |
| audio.addEventListener('playing', () => {{ |
| clearTimeout(pauseDebounce); |
| speakingLockUntil = performance.now() + 800; |
| setSpeaking(true); |
| setupAudioMeter(audio); |
| if (window._aikoLatestTtsText) {{ |
| setSpeechText(window._aikoLatestTtsText, audio.duration); |
| window._aikoLatestTtsText = ''; |
| }} |
| }}); |
| |
| audio.addEventListener('timeupdate', () => {{ |
| if (!audio.paused && audio.currentTime > 0) {{ |
| setSpeaking(true); |
| const curSrc = audio.currentSrc || audio.src || ''; |
| if (curSrc !== meteredSrc) setupAudioMeter(audio); |
| }} |
| }}); |
| audio.addEventListener('pause', () => {{ |
| clearTimeout(pauseDebounce); |
| pauseDebounce = setTimeout(() => {{ |
| if (audio.paused || audio.ended) setSpeaking(false); |
| }}, 200); |
| }}); |
| audio.addEventListener('ended', () => {{ |
| clearTimeout(pauseDebounce); |
| pauseDebounce = setTimeout(() => setSpeaking(false), 150); |
| }}); |
| }} |
| syncAudioState(audio); |
| }} |
| |
| setInterval(() => attachAudio(findParentAudio()), 500); |
| |
| // ── postMessage API ─────────────────────────────────────────────────────── |
| window.addEventListener('message', (e) => {{ |
| try {{ |
| const msg = (typeof e.data === 'string') ? JSON.parse(e.data) : e.data; |
| |
| if (msg.status !== undefined) {{ |
| setStatus(msg.status); |
| }} |
| |
| const incomingText = msg.ttsText ?? msg.speechText ?? msg.text; |
| if (incomingText !== undefined) {{ |
| window._aikoLatestTtsText = incomingText; |
| setSpeechText(incomingText, msg.duration ?? msg.audioDuration ?? null); |
| if (msg.speaking === undefined && msg.playNow) {{ |
| setStatus('speaking'); |
| speakingLockUntil = performance.now() + 400; |
| }} |
| }} |
| if (msg.speaking !== undefined) {{ |
| if (msg.speaking) speakingLockUntil = performance.now() + 400; |
| setSpeaking(msg.speaking); |
| }} |
| |
| if (msg.expression !== undefined) {{ |
| const isPersistent = msg.persistent !== false; |
| setExpression(msg.expression, msg.intensity ?? 1.0, isPersistent); |
| clearTimeout(exprResetTimer); |
| }} |
| |
| if (msg.viseme !== undefined) {{ |
| setMouth(msg.weight ?? 1.0, msg.viseme); |
| clearTimeout(window._aikoMouthTimer); |
| window._aikoMouthTimer = setTimeout(clearMouth, 180); |
| }} |
| |
| }} catch (_) {{}} |
| }}); |
| |
| // ── VRM loader ──────────────────────────────────────────────────────────── |
| const loader = new GLTFLoader(); |
| loader.register(parser => new VRMLoaderPlugin(parser)); |
| |
| function loadVrm(index = 0) {{ |
| const url = VRM_URLS[index]; |
| if (!url) {{ |
| document.getElementById('load-msg').textContent = 'VRM load failed'; |
| return; |
| }} |
| document.getElementById('load-msg').textContent = `loading VRM (${{index + 1}}/${{VRM_URLS.length}})…`; |
| loader.load(url, gltf => {{ |
| vrm = gltf.userData.vrm; |
| window._aikoVrm = vrm; |
| VRMUtils.removeUnnecessaryVertices(vrm.scene); |
| vrm.scene.traverse(o => {{ if (o.frustumCulled) o.frustumCulled = false; }}); |
| vrm.scene.rotation.y = 0; |
| scene.add(vrm.scene); |
| |
| setTimeout(() => {{ |
| const lUA = vrm.humanoid?.getRawBoneNode('leftUpperArm'); |
| const rUA = vrm.humanoid?.getRawBoneNode('rightUpperArm'); |
| console.log('[aiko-vrm] leftUpperArm rotation:', lUA?.rotation); |
| console.log('[aiko-vrm] rightUpperArm rotation:', rUA?.rotation); |
| }}, 500); |
| |
| setExpression('relaxed', 0.25); |
| console.log('Available expressions:', expressionNames()); |
| document.getElementById('loader').classList.add('fade'); |
| setTimeout(() => document.getElementById('loader').remove(), 550); |
| }}, undefined, err => {{ |
| console.warn('[aiko-vrm] candidate failed', url, err); |
| loadVrm(index + 1); |
| }}); |
| }} |
| |
| loadVrm(); |
| |
| // ── Render loop ─────────────────────────────────────────────────────────── |
| function tick() {{ |
| requestAnimationFrame(tick); |
| resize(); |
| const dt = Math.min(clock.getDelta(), 0.05); |
| controls.update(); |
| if (vrm) vrm.update(dt); |
| applyIdle(dt); |
| applyGestures(dt); |
| |
| const now = performance.now(); |
| const textMouth = speaking ? currentTextMouth(now) : null; |
| const audioMouth = speaking ? getAudioMouth() : null; |
| |
| if (audioMouth !== null) {{ |
| setMouth(audioMouth, textMouth?.viseme ?? 'aa'); |
| }} else if (textMouth) {{ |
| setMouth(textMouth.weight, textMouth.viseme); |
| }} else if (speaking) {{ |
| mouth = 0.12 + Math.abs(Math.sin(now / 110)) * 0.65; |
| setMouth(mouth, 'aa'); |
| }} else {{ |
| smoothedAudioMouth = 0; |
| clearMouth(); |
| }} |
| |
| applyBlink(dt); |
| renderer.render(scene, camera); |
| }} |
| tick(); |
| </script> |
| </body> |
| </html>""" |
|
|
| return ( |
| '<iframe id="aiko-vrm-frame" title="Aiko VRM Avatar" ' |
| 'sandbox="allow-scripts allow-same-origin" ' |
| f'srcdoc="{html.escape(srcdoc, quote=True)}"></iframe>' |
| ) |