Files
product/web/app.js
T
2026-03-27 17:10:41 +08:00

955 lines
32 KiB
JavaScript

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";
const statusEl = document.getElementById("status");
const outputEl = document.getElementById("output");
const healthBtn = document.getElementById("healthBtn");
const offerBtn = document.getElementById("offerBtn");
const resetBtn = document.getElementById("resetBtn");
const sendBtn = document.getElementById("sendBtn");
const chatInput = document.getElementById("chatInput");
const remoteAudio = document.getElementById("remoteAudio");
const modelFile = document.getElementById("modelFile");
const modelUrl = document.getElementById("modelUrl");
const loadModelBtn = document.getElementById("loadModelBtn");
const resetViewBtn = document.getElementById("resetViewBtn");
const avatarCanvas = document.getElementById("avatarCanvas");
const modelStatus = document.getElementById("modelStatus");
const modelName = document.getElementById("modelName");
const animationBuffer = document.getElementById("animationBuffer");
remoteAudio.muted = false;
remoteAudio.volume = 1.0;
const messagesEl = document.getElementById("messages");
const connBadge = document.getElementById("connBadge");
const animationConn = document.getElementById("animationConn");
const animationDriver = document.getElementById("animationDriver");
const animationFrames = document.getElementById("animationFrames");
const animationPreview = document.getElementById("animationPreview");
const mPeers = document.getElementById("mPeers");
const mBusy = document.getElementById("mBusy");
const mLatency = document.getElementById("mLatency");
const mAsrLatency = document.getElementById("mAsrLatency");
const mLlmLatency = document.getElementById("mLlmLatency");
const mLlmSource = document.getElementById("mLlmSource");
const mLlmHistory = document.getElementById("mLlmHistory");
const mTtsLatency = document.getElementById("mTtsLatency");
const mTtsFirst = document.getElementById("mTtsFirst");
const mTts = document.getElementById("mTts");
const mBargeIn = document.getElementById("mBargeIn");
const mInputMode = document.getElementById("mInputMode");
const mVadCount = document.getElementById("mVadCount");
const mAnimClients = document.getElementById("mAnimClients");
const mAnimDriver = document.getElementById("mAnimDriver");
const mAnimFrames = document.getElementById("mAnimFrames");
const dConn = document.getElementById("dConn");
const dVad = document.getElementById("dVad");
const dAsr = document.getElementById("dAsr");
const dLlm = document.getElementById("dLlm");
const dTts = document.getElementById("dTts");
let pc = null;
let evt = null;
let subtitleWs = null;
let animationWs = null;
let localMicStream = null;
let aiStreamingEl = null;
const AUTO_RECONNECT_KEY = "visual_chat_auto_reconnect";
const ANIMATION_BUFFER_MS = 120;
const DEFAULT_BODY_MODEL_URL = "https://cdn.jsdelivr.net/gh/pixiv/three-vrm@release/packages/three-vrm/examples/models/VRM1_Constraint_Twist_Sample.vrm";
const DEFAULT_VRM_FALLBACK_URL = "https://raw.githubusercontent.com/pixiv/three-vrm/dev/packages/three-vrm/examples/models/VRM1_Constraint_Twist_Sample.vrm";
const CLOCK = new THREE.Clock();
const stateMap = {
"SessionState.IDLE": "空闲",
"SessionState.USER_SPEAKING": "用户说话",
"SessionState.THINKING": "思考中",
"SessionState.AVATAR_SPEAKING": "数字人说话",
idle: "空闲",
user_speaking: "用户说话",
thinking: "思考中",
avatar_speaking: "数字人说话",
};
const renderer = new THREE.WebGLRenderer({ canvas: avatarCanvas, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0x0a1017, 5.5, 11.5);
const camera = new THREE.PerspectiveCamera(30, 16 / 9, 0.1, 100);
camera.position.set(0, 1.45, 2.65);
const controls = new OrbitControls(camera, avatarCanvas);
controls.target.set(0, 1.15, 0);
controls.enableDamping = true;
controls.minDistance = 1.3;
controls.maxDistance = 4.5;
controls.maxPolarAngle = Math.PI * 0.58;
controls.minPolarAngle = Math.PI * 0.22;
const avatarState = {
currentRoot: null,
currentVrm: null,
morphBindings: new Map(),
pendingFrames: [],
scheduleCursorMs: performance.now(),
lastAppliedSeq: -1,
activeControls: {
jawOpen: 0,
viseme_aa: 0,
viseme_ee: 0,
viseme_oh: 0,
mouthPucker: 0,
headYaw: 0,
headPitch: 0,
headRoll: 0,
},
headBone: null,
neckBone: null,
idleRig: null,
debugRig: null,
debugJaw: null,
debugMouth: null,
};
const gltfLoader = new GLTFLoader();
gltfLoader.register((parser) => new VRMLoaderPlugin(parser));
gltfLoader.crossOrigin = "anonymous";
const TMP_EULER = new THREE.Euler();
const TMP_QUATERNION = new THREE.Quaternion();
function fmtTime() {
return new Date().toLocaleTimeString("zh-CN", { hour12: false });
}
function toCnState(raw) {
return stateMap[raw] || raw;
}
function addMessage(role, text) {
const row = document.createElement("div");
row.className = "msg-row";
const div = document.createElement("div");
div.className = `msg ${role}`;
div.textContent = text;
const time = document.createElement("div");
time.className = `msg-time ${role}`;
time.textContent = fmtTime();
row.appendChild(div);
row.appendChild(time);
messagesEl.appendChild(row);
messagesEl.scrollTop = messagesEl.scrollHeight;
return div;
}
function setConnBadge(label, kind = "neutral") {
connBadge.textContent = label;
connBadge.className = `badge ${kind}`;
}
function setDot(el, kind) {
el.className = `dot ${kind}`;
}
function initStage() {
scene.background = new THREE.Color(0x0b1118);
const hemiLight = new THREE.HemisphereLight(0xb8d7ff, 0x142334, 1.65);
hemiLight.position.set(0, 2.6, 0);
scene.add(hemiLight);
const keyLight = new THREE.DirectionalLight(0xffffff, 1.7);
keyLight.position.set(1.4, 2.2, 2.0);
scene.add(keyLight);
const rimLight = new THREE.DirectionalLight(0x67c9ff, 0.8);
rimLight.position.set(-2.0, 1.4, -1.5);
scene.add(rimLight);
const floor = new THREE.Mesh(
new THREE.CircleGeometry(2.8, 48),
new THREE.MeshStandardMaterial({
color: 0x101b28,
roughness: 0.94,
metalness: 0.08,
transparent: true,
opacity: 0.9,
}),
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -0.02;
scene.add(floor);
const halo = new THREE.Mesh(
new THREE.RingGeometry(1.2, 1.95, 64),
new THREE.MeshBasicMaterial({ color: 0x153a62, transparent: true, opacity: 0.28, side: THREE.DoubleSide }),
);
halo.rotation.x = -Math.PI / 2;
halo.position.y = 0.01;
scene.add(halo);
createDebugRig();
resizeStage();
renderLoop();
}
function createDebugRig() {
const group = new THREE.Group();
const bustMaterial = new THREE.MeshStandardMaterial({
color: 0xc7d2e0,
roughness: 0.72,
metalness: 0.05,
});
const accentMaterial = new THREE.MeshStandardMaterial({
color: 0x0f172a,
roughness: 0.35,
metalness: 0.18,
});
const lipMaterial = new THREE.MeshStandardMaterial({
color: 0x8b425f,
roughness: 0.56,
metalness: 0.02,
});
const torso = new THREE.Mesh(new THREE.CapsuleGeometry(0.34, 0.72, 8, 14), bustMaterial);
torso.position.set(0, 0.42, 0);
group.add(torso);
const neck = new THREE.Group();
neck.position.set(0, 0.95, 0);
group.add(neck);
const head = new THREE.Mesh(new THREE.SphereGeometry(0.34, 32, 32), bustMaterial);
head.scale.set(0.92, 1.03, 0.92);
neck.add(head);
const hair = new THREE.Mesh(new THREE.SphereGeometry(0.35, 32, 32, 0, Math.PI * 2, 0, Math.PI * 0.58), accentMaterial);
hair.position.set(0, 0.06, 0);
neck.add(hair);
const leftEye = new THREE.Mesh(new THREE.SphereGeometry(0.028, 16, 16), accentMaterial);
leftEye.position.set(-0.1, 0.03, 0.28);
neck.add(leftEye);
const rightEye = new THREE.Mesh(new THREE.SphereGeometry(0.028, 16, 16), accentMaterial);
rightEye.position.set(0.1, 0.03, 0.28);
neck.add(rightEye);
const jawPivot = new THREE.Group();
jawPivot.position.set(0, -0.06, 0.15);
neck.add(jawPivot);
const jaw = new THREE.Mesh(new THREE.BoxGeometry(0.24, 0.08, 0.16), bustMaterial);
jaw.position.set(0, -0.08, 0.02);
jawPivot.add(jaw);
const mouth = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.025, 0.02), lipMaterial);
mouth.position.set(0, -0.02, 0.31);
neck.add(mouth);
scene.add(group);
avatarState.currentRoot = group;
avatarState.debugRig = group;
avatarState.debugJaw = jawPivot;
avatarState.debugMouth = mouth;
avatarState.headBone = neck;
avatarState.neckBone = neck;
}
function resizeStage() {
const rect = avatarCanvas.getBoundingClientRect();
if (!rect.width || !rect.height) return;
renderer.setSize(rect.width, rect.height, false);
camera.aspect = rect.width / rect.height;
camera.updateProjectionMatrix();
}
function renderLoop() {
requestAnimationFrame(renderLoop);
const delta = CLOCK.getDelta();
const elapsed = CLOCK.getElapsedTime();
controls.update();
flushAnimationQueue();
applyProceduralIdlePose(elapsed);
applyAnimationControls(avatarState.activeControls);
avatarState.currentVrm?.update?.(delta);
renderer.render(scene, camera);
}
function updateMetrics(data) {
mPeers.textContent = String(data.peers ?? 0);
mBusy.textContent = data.pipeline_busy ? "是" : "否";
mLatency.textContent = `${data.last_latency_ms ?? 0} ms`;
mAsrLatency.textContent = `${data.last_asr_latency_ms ?? 0} ms`;
mLlmLatency.textContent = `${data.last_llm_latency_ms ?? 0} ms`;
mLlmSource.textContent = data.last_llm_source === "online" ? "在线" : "兜底";
mLlmHistory.textContent = String(data?.llm?.history_items ?? 0);
mTtsLatency.textContent = `${data.last_tts_latency_ms ?? 0} ms`;
mTtsFirst.textContent = `${data.last_tts_first_chunk_ms ?? 0} ms`;
mTts.textContent = data?.tts?.ready ? "就绪" : "未就绪";
mBargeIn.textContent = `${data.last_barge_in_ms ?? 0} ms (${data.barge_in_count ?? 0})`;
mInputMode.textContent = data.last_input_mode === "voice" ? "语音" : data.last_input_mode === "text" ? "文本" : "none";
mVadCount.textContent = `${data.vad_start_count ?? 0}/${data.vad_end_count ?? 0}`;
mAnimClients.textContent = String(data.animation_clients ?? 0);
mAnimDriver.textContent = data?.avatar?.driver || data.last_animation_mode || "--";
mAnimFrames.textContent = String(data.last_animation_frame_count ?? data?.avatar?.last_frame_count ?? 0);
setDot(dConn, (data.peers ?? 0) > 0 ? "ok" : "warn");
setDot(dVad, (data.vad_start_count ?? 0) > 0 ? "ok" : "warn");
setDot(dAsr, data?.asr?.ready ? "ok" : "warn");
setDot(dLlm, data?.llm?.ready ? "ok" : "bad");
setDot(dTts, data?.tts?.ready ? "ok" : "warn");
}
function queueAnimationFrames(payload) {
const frames = Array.isArray(payload.frames) ? payload.frames : [];
const now = performance.now();
const frameWindowMs = (1000 / Math.max(1, payload.fps || 25));
if (payload.type === "animation_reset" || payload.type === "animation_state") {
avatarState.pendingFrames = [];
avatarState.scheduleCursorMs = now + ANIMATION_BUFFER_MS;
avatarState.activeControls = {
jawOpen: 0,
viseme_aa: 0,
viseme_ee: 0,
viseme_oh: 0,
mouthPucker: 0,
headYaw: 0,
headPitch: 0,
headRoll: 0,
};
}
const baseTime = Math.max(now + ANIMATION_BUFFER_MS, avatarState.scheduleCursorMs);
frames.forEach((frame, index) => {
const fallbackTime = index * frameWindowMs;
avatarState.pendingFrames.push({
dueAt: baseTime + (frame.time_ms ?? fallbackTime),
seq: frame.seq ?? index,
controls: frame.controls || {},
});
});
avatarState.pendingFrames.sort((a, b) => a.dueAt - b.dueAt || a.seq - b.seq);
avatarState.scheduleCursorMs = baseTime + (payload.duration_ms ?? frames.length * frameWindowMs);
animationBuffer.textContent = String(avatarState.pendingFrames.length);
}
function flushAnimationQueue() {
const now = performance.now();
let frameToApply = null;
while (avatarState.pendingFrames.length > 0 && avatarState.pendingFrames[0].dueAt <= now) {
frameToApply = avatarState.pendingFrames.shift();
}
if (frameToApply && frameToApply.seq !== avatarState.lastAppliedSeq) {
avatarState.lastAppliedSeq = frameToApply.seq;
avatarState.activeControls = {
...avatarState.activeControls,
...frameToApply.controls,
};
}
animationBuffer.textContent = String(avatarState.pendingFrames.length);
}
function applyAnimationControls(controls) {
const safeControls = controls || {};
const jawOpen = clampControl(safeControls.jawOpen);
const visemeAa = clampControl(safeControls.viseme_aa ?? jawOpen);
const visemeEe = clampControl(safeControls.viseme_ee);
const visemeOh = clampControl(safeControls.viseme_oh);
const mouthPucker = clampControl(safeControls.mouthPucker);
const headYaw = clampSigned(safeControls.headYaw, 0.35);
const headPitch = clampSigned(safeControls.headPitch, 0.3);
const headRoll = clampSigned(safeControls.headRoll, 0.24);
const hasFacialRig = Boolean(avatarState.currentVrm?.expressionManager) || avatarState.morphBindings.size > 0;
const t = performance.now() * 0.001;
if (avatarState.currentVrm?.expressionManager) {
setExpressionValue("aa", visemeAa);
setExpressionValue("ee", visemeEe);
setExpressionValue("oh", visemeOh);
setExpressionValue("ou", Math.max(visemeOh * 0.35, mouthPucker));
}
if (avatarState.morphBindings.size > 0) {
setMorphValue("jawOpen", jawOpen);
setMorphValue("viseme_aa", visemeAa);
setMorphValue("viseme_ee", visemeEe);
setMorphValue("viseme_oh", visemeOh);
setMorphValue("mouthPucker", mouthPucker);
}
// For plain head meshes (no VRM expressions and no morph targets),
// amplify head motion to keep speaking visually obvious.
const yawApplied = hasFacialRig ? headYaw * 1.8 : headYaw * 10.0 + Math.sin(t * 5.0) * jawOpen * 0.09;
const pitchApplied = hasFacialRig ? headPitch * 1.6 : headPitch * 10.0 + jawOpen * 0.12;
const rollApplied = hasFacialRig ? headRoll * 1.6 : headRoll * 8.0 + Math.sin(t * 4.0) * jawOpen * 0.04;
applyBoneRotation(avatarState.headBone, pitchApplied, yawApplied, rollApplied);
if (avatarState.neckBone && avatarState.neckBone !== avatarState.headBone) {
applyBoneRotation(avatarState.neckBone, pitchApplied * 0.4, yawApplied * 0.5, rollApplied * 0.4);
}
if (!hasFacialRig && avatarState.currentRoot && avatarState.currentRoot !== avatarState.debugRig) {
const baseScale = 1.0 + jawOpen * 0.03;
avatarState.currentRoot.scale.set(baseScale, baseScale, baseScale);
}
if (avatarState.debugJaw) {
avatarState.debugJaw.rotation.x = jawOpen * 0.5;
avatarState.debugJaw.position.y = -0.06 - jawOpen * 0.015;
}
if (avatarState.debugMouth) {
avatarState.debugMouth.scale.x = 1.0 + mouthPucker * 0.45;
avatarState.debugMouth.scale.y = 1.0 + jawOpen * 3.8;
avatarState.debugMouth.position.z = 0.31 + mouthPucker * 0.01;
}
}
function setExpressionValue(name, value) {
try {
avatarState.currentVrm.expressionManager.setValue(name, value);
} catch (_) {
// no-op: expression name may not exist on the current model
}
}
function setMorphValue(controlName, value) {
const bindings = avatarState.morphBindings.get(controlName) || [];
bindings.forEach((binding) => {
binding.mesh.morphTargetInfluences[binding.index] = value;
});
}
function clampControl(value) {
return Math.min(1, Math.max(0, Number(value) || 0));
}
function clampSigned(value, limit) {
return Math.min(limit, Math.max(-limit, Number(value) || 0));
}
function resetView() {
camera.position.set(0, 1.45, 2.65);
controls.target.set(0, 1.15, 0);
controls.update();
}
function resetAvatarPose() {
avatarState.activeControls = {
jawOpen: 0,
viseme_aa: 0,
viseme_ee: 0,
viseme_oh: 0,
mouthPucker: 0,
headYaw: 0,
headPitch: 0,
headRoll: 0,
};
applyAnimationControls(avatarState.activeControls);
}
function detachCurrentAvatar() {
if (avatarState.currentRoot && avatarState.currentRoot !== avatarState.debugRig) {
scene.remove(avatarState.currentRoot);
}
avatarState.pendingFrames = [];
avatarState.scheduleCursorMs = performance.now() + ANIMATION_BUFFER_MS;
avatarState.lastAppliedSeq = -1;
avatarState.idleRig = null;
avatarState.currentRoot = avatarState.debugRig;
avatarState.currentVrm = null;
avatarState.morphBindings.clear();
avatarState.headBone = avatarState.debugRig.children[1];
avatarState.neckBone = avatarState.debugRig.children[1];
avatarState.debugRig.visible = true;
modelName.textContent = "debug-avatar";
}
async function loadModelFromUrl(url) {
if (!url) return false;
modelStatus.textContent = "模型加载中...";
try {
const gltf = await gltfLoader.loadAsync(url);
attachLoadedAsset(gltf, url.split("/").pop() || url);
modelStatus.textContent = "模型已加载";
return true;
} catch (error) {
console.error(error);
modelStatus.textContent = "模型加载失败,已回退调试头像";
addMessage("system", `模型加载失败:${error?.message || error}`);
detachCurrentAvatar();
return false;
}
}
async function loadDefaultAvatarModel() {
modelUrl.value = DEFAULT_BODY_MODEL_URL;
const ok = await loadModelFromUrl(DEFAULT_BODY_MODEL_URL);
if (!ok) {
modelUrl.value = DEFAULT_VRM_FALLBACK_URL;
await loadModelFromUrl(DEFAULT_VRM_FALLBACK_URL);
}
}
async function loadModelFromFile(file) {
if (!file) return;
const url = URL.createObjectURL(file);
modelStatus.textContent = "本地模型加载中...";
try {
const gltf = await gltfLoader.loadAsync(url);
attachLoadedAsset(gltf, file.name);
modelStatus.textContent = "本地模型已加载";
} catch (error) {
console.error(error);
modelStatus.textContent = "本地模型加载失败,已回退调试头像";
addMessage("system", `本地模型加载失败:${error?.message || error}`);
detachCurrentAvatar();
} finally {
URL.revokeObjectURL(url);
}
}
function attachLoadedAsset(gltf, label) {
detachCurrentAvatar();
const vrm = gltf.userData?.vrm || null;
const root = vrm?.scene || gltf.scene;
if (!root) {
throw new Error("模型中未找到可渲染的 scene");
}
if (vrm) {
VRMUtils.removeUnnecessaryVertices(gltf.scene);
VRMUtils.combineSkeletons(gltf.scene);
VRMUtils.combineMorphs(vrm);
VRMUtils.rotateVRM0(vrm);
}
root.traverse((obj) => {
obj.frustumCulled = false;
});
root.position.set(0, 0, 0);
root.rotation.set(0, 0, 0);
root.scale.setScalar(1);
scene.add(root);
avatarState.currentRoot = root;
avatarState.currentVrm = vrm;
avatarState.debugRig.visible = false;
avatarState.morphBindings = collectMorphBindings(root);
avatarState.headBone = resolveHeadBone(vrm, root) || avatarState.debugRig.children[1];
avatarState.neckBone = resolveNeckBone(vrm, root) || avatarState.headBone;
avatarState.idleRig = buildIdleRig(vrm, root);
avatarState.pendingFrames = [];
avatarState.scheduleCursorMs = performance.now() + ANIMATION_BUFFER_MS;
avatarState.lastAppliedSeq = -1;
fitCameraToObject(root);
resetAvatarPose();
modelName.textContent = label;
}
function fitCameraToObject(root) {
const box = new THREE.Box3().setFromObject(root);
const center = new THREE.Vector3();
const size = new THREE.Vector3();
box.getCenter(center);
box.getSize(size);
const radius = Math.max(size.x, size.y, size.z, 1.2);
controls.target.copy(center).add(new THREE.Vector3(0, size.y * 0.1, 0));
camera.position.set(center.x, center.y + size.y * 0.1, center.z + radius * 1.9);
controls.update();
}
function resolveHeadBone(vrm, root) {
return (
vrm?.humanoid?.getNormalizedBoneNode?.("head") ||
vrm?.humanoid?.getRawBoneNode?.("head") ||
root.getObjectByName("Head") ||
root.getObjectByName("head") ||
root
);
}
function resolveNeckBone(vrm, root) {
return (
vrm?.humanoid?.getNormalizedBoneNode?.("neck") ||
vrm?.humanoid?.getRawBoneNode?.("neck") ||
root.getObjectByName("Neck") ||
root.getObjectByName("neck") ||
resolveHeadBone(vrm, root)
);
}
function collectMorphBindings(root) {
const bindings = new Map();
const aliases = {
jawOpen: ["jawopen", "jaw_open", "mouthopen", "mouth_open", "aa", "viseme_aa"],
viseme_aa: ["viseme_aa", "aa", "a", "moutha"],
viseme_ee: ["viseme_ee", "ee", "ih", "i", "mouthee"],
viseme_oh: ["viseme_oh", "oh", "ou", "o", "mouthoh"],
mouthPucker: ["mouthpucker", "pucker", "ou", "kiss"],
};
root.traverse((node) => {
if (!node.isMesh || !node.morphTargetDictionary || !node.morphTargetInfluences) return;
const entries = Object.entries(node.morphTargetDictionary);
Object.entries(aliases).forEach(([controlName, names]) => {
entries.forEach(([targetName, index]) => {
const normalized = targetName.toLowerCase();
if (!names.includes(normalized)) return;
if (!bindings.has(controlName)) bindings.set(controlName, []);
bindings.get(controlName).push({ mesh: node, index });
});
});
});
return bindings;
}
function buildIdleRig(vrm, root) {
const lookup = {
hips: resolveHumanoidBone(vrm, root, "hips", ["Hips", "hips"]),
spine: resolveHumanoidBone(vrm, root, "spine", ["Spine", "spine"]),
chest: resolveHumanoidBone(vrm, root, "chest", ["Chest", "chest", "UpperChest", "upperChest"]),
upperChest: resolveHumanoidBone(vrm, root, "upperChest", ["UpperChest", "upperChest", "Chest", "chest"]),
neck: resolveHumanoidBone(vrm, root, "neck", ["Neck", "neck"]),
head: resolveHumanoidBone(vrm, root, "head", ["Head", "head"]),
leftShoulder: resolveHumanoidBone(vrm, root, "leftShoulder", ["LeftShoulder", "leftShoulder", "Shoulder_L"]),
rightShoulder: resolveHumanoidBone(vrm, root, "rightShoulder", ["RightShoulder", "rightShoulder", "Shoulder_R"]),
leftUpperArm: resolveHumanoidBone(vrm, root, "leftUpperArm", ["LeftUpperArm", "leftUpperArm", "Arm_L"]),
rightUpperArm: resolveHumanoidBone(vrm, root, "rightUpperArm", ["RightUpperArm", "rightUpperArm", "Arm_R"]),
leftLowerArm: resolveHumanoidBone(vrm, root, "leftLowerArm", ["LeftLowerArm", "leftLowerArm", "ForeArm_L"]),
rightLowerArm: resolveHumanoidBone(vrm, root, "rightLowerArm", ["RightLowerArm", "rightLowerArm", "ForeArm_R"]),
leftHand: resolveHumanoidBone(vrm, root, "leftHand", ["LeftHand", "leftHand", "Hand_L"]),
rightHand: resolveHumanoidBone(vrm, root, "rightHand", ["RightHand", "rightHand", "Hand_R"]),
};
const base = new Map();
Object.values(lookup).forEach((bone) => {
if (bone && !base.has(bone)) {
base.set(bone, bone.quaternion.clone());
}
});
return { bones: lookup, base };
}
function resolveHumanoidBone(vrm, root, humanoidName, fallbacks) {
return (
vrm?.humanoid?.getNormalizedBoneNode?.(humanoidName) ||
vrm?.humanoid?.getRawBoneNode?.(humanoidName) ||
fallbacks.map((name) => root.getObjectByName(name)).find(Boolean) ||
null
);
}
function applyProceduralIdlePose(time) {
const rig = avatarState.idleRig;
if (!rig) return;
const breathe = Math.sin(time * 1.9) * 0.018;
const sway = Math.sin(time * 1.15) * 0.05;
const armDrift = Math.sin(time * 1.55) * 0.07;
const forearmDrift = Math.sin(time * 1.55 + 0.8) * 0.06;
const handDrift = Math.sin(time * 2.3) * 0.04;
applyBoneRotation(rig.bones.hips, 0, sway * 0.08, sway * 0.04);
applyBoneRotation(rig.bones.spine, breathe, sway * 0.18, sway * 0.06);
applyBoneRotation(rig.bones.chest, breathe * 1.4, sway * 0.24, sway * 0.08);
applyBoneRotation(rig.bones.upperChest, breathe * 1.6, sway * 0.28, sway * 0.1);
applyBoneRotation(rig.bones.leftShoulder, 0.04, 0.03, -0.18);
applyBoneRotation(rig.bones.rightShoulder, 0.04, -0.03, 0.18);
applyBoneRotation(rig.bones.leftUpperArm, 0.12 + armDrift * 0.08, 0.03, -1.0);
applyBoneRotation(rig.bones.rightUpperArm, 0.12 + armDrift * 0.08, -0.03, 1.0);
applyBoneRotation(rig.bones.leftLowerArm, -0.14 + forearmDrift * 0.08, 0, -0.12);
applyBoneRotation(rig.bones.rightLowerArm, -0.14 + forearmDrift * 0.08, 0, 0.12);
applyBoneRotation(rig.bones.leftHand, handDrift * 0.2, 0, -0.05);
applyBoneRotation(rig.bones.rightHand, handDrift * 0.2, 0, 0.05);
}
function applyBoneRotation(bone, x = 0, y = 0, z = 0) {
if (!bone) return;
const baseQuaternion = avatarState.idleRig?.base?.get(bone);
if (baseQuaternion) {
bone.quaternion.copy(baseQuaternion);
TMP_EULER.set(x, y, z, "XYZ");
TMP_QUATERNION.setFromEuler(TMP_EULER);
bone.quaternion.multiply(TMP_QUATERNION);
return;
}
bone.rotation.x = x;
bone.rotation.y = y;
bone.rotation.z = z;
}
async function checkHealth() {
const res = await fetch("/health");
const data = await res.json();
statusEl.textContent = `状态:${toCnState(data.state)}`;
outputEl.textContent = JSON.stringify(data, null, 2);
sendBtn.disabled = !!data.pipeline_busy;
updateMetrics(data);
}
function subscribeEvents() {
if (evt) return;
evt = new EventSource("/events");
evt.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
statusEl.textContent = `状态:${toCnState(data.state)}`;
outputEl.textContent = JSON.stringify(data, null, 2);
sendBtn.disabled = !!data.pipeline_busy;
updateMetrics(data);
} catch (_) {
// no-op
}
};
}
function subscribeSubtitles() {
if (subtitleWs && (subtitleWs.readyState === WebSocket.OPEN || subtitleWs.readyState === WebSocket.CONNECTING)) return;
const scheme = location.protocol === "https:" ? "wss" : "ws";
subtitleWs = new WebSocket(`${scheme}://${location.host}/ws/subtitles`);
subtitleWs.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.role === "user") {
aiStreamingEl = null;
addMessage(data.role, data.text || "");
} else if (data.role === "ai") {
if (data.partial) {
if (!aiStreamingEl) aiStreamingEl = addMessage("ai", "");
aiStreamingEl.textContent = `${aiStreamingEl.textContent}${data.text || ""}`;
if (data.final) aiStreamingEl = null;
} else {
aiStreamingEl = null;
addMessage("ai", data.text || "");
}
}
} catch (_) {
// no-op
}
};
subtitleWs.onclose = () => {
setTimeout(subscribeSubtitles, 1000);
};
}
function subscribeAnimation() {
if (animationWs && (animationWs.readyState === WebSocket.OPEN || animationWs.readyState === WebSocket.CONNECTING)) return;
const scheme = location.protocol === "https:" ? "wss" : "ws";
animationConn.textContent = "连接中";
animationWs = new WebSocket(`${scheme}://${location.host}/ws/animation`);
animationWs.onopen = () => {
animationConn.textContent = "已连接";
};
animationWs.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "ping") return;
if (data.driver) {
animationDriver.textContent = data.driver;
}
const frameCount = data.frame_count ?? data.frames?.length ?? 0;
animationFrames.textContent = String(frameCount);
const preview = data.frames?.[0]?.controls || null;
animationPreview.textContent = preview ? JSON.stringify(preview, null, 2) : JSON.stringify(data, null, 2);
queueAnimationFrames(data);
} catch (_) {
// no-op
}
};
animationWs.onclose = () => {
animationConn.textContent = "已断开";
setTimeout(subscribeAnimation, 1000);
};
}
async function connectWebRTC() {
if (pc) {
outputEl.textContent = "WebRTC 已连接或正在连接";
return;
}
setConnBadge("连接中...", "warn");
offerBtn.disabled = true;
const metaRes = await fetch("/meta");
const meta = await metaRes.json();
pc = new RTCPeerConnection({
iceServers: [{ urls: [meta.stun_url || "stun:stun.l.google.com:19302"] }],
});
const currentPc = pc;
let seenConnected = false;
currentPc.onconnectionstatechange = () => {
const st = currentPc.connectionState;
if (st === "connected") {
seenConnected = true;
setConnBadge("已连接", "ok");
localStorage.setItem(AUTO_RECONNECT_KEY, "1");
} else if (st === "connecting") {
setConnBadge("连接中...", "warn");
} else if (st === "failed" || st === "disconnected" || st === "closed") {
setConnBadge(`连接${st}`, "warn");
if (seenConnected && (st === "disconnected" || st === "failed")) {
addMessage("system", "连接已断开,可能被新的客户端连接接管。");
}
if (localMicStream) {
localMicStream.getTracks().forEach((track) => track.stop());
localMicStream = null;
}
if (pc === currentPc) {
pc = null;
}
offerBtn.disabled = false;
}
};
pc.ontrack = (event) => {
if (event.streams && event.streams[0]) {
remoteAudio.srcObject = event.streams[0];
remoteAudio.play().catch(() => {
addMessage("system", "远端语音已到达,如未自动播放请先与页面交互后重试。");
});
}
};
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
localMicStream = stream;
} catch (err) {
statusEl.textContent = "状态:麦克风权限失败";
outputEl.textContent = `getUserMedia 失败:${err?.message || err}\n提示:跨机器访问请使用 HTTPS 或浏览器安全例外。`;
setConnBadge("麦克风失败", "warn");
offerBtn.disabled = false;
throw err;
}
stream.getTracks().forEach((track) => pc.addTrack(track, stream));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const res = await fetch("/webrtc/offer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sdp: pc.localDescription.sdp,
type: pc.localDescription.type,
}),
});
const answer = await res.json();
await pc.setRemoteDescription(answer);
statusEl.textContent = "状态:WebRTC 已连接";
outputEl.textContent = JSON.stringify({ connected: true, iceConnectionState: pc.iceConnectionState }, null, 2);
setConnBadge("已连接", "ok");
addMessage("system", "WebRTC 已连接,可开始对话。");
if (answer.replaced_previous) {
addMessage("system", "检测到已有旧连接,已自动断开旧连接并由当前页面接管。");
}
subscribeEvents();
}
async function sendText() {
const text = (chatInput.value || "").trim();
if (!text || sendBtn.disabled) return;
sendBtn.disabled = true;
const res = await fetch("/chat/text", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
const data = await res.json();
outputEl.textContent = JSON.stringify(data, null, 2);
if (data.ok) {
chatInput.value = "";
} else if (data.message) {
addMessage("system", `发送失败:${data.message}`);
}
sendBtn.disabled = false;
}
async function resetChat() {
await fetch("/chat/reset", { method: "POST" });
messagesEl.innerHTML = "";
avatarState.pendingFrames = [];
avatarState.scheduleCursorMs = performance.now() + ANIMATION_BUFFER_MS;
resetAvatarPose();
addMessage("system", "会话已重置。");
await checkHealth();
}
function tryAutoReconnect() {
if (localStorage.getItem(AUTO_RECONNECT_KEY) !== "1") return;
setTimeout(() => {
if (!pc) {
connectWebRTC().catch(() => {
// no-op
});
}
}, 300);
}
function handleModelFileChange(event) {
const [file] = event.target.files || [];
if (!file) return;
loadModelFromFile(file);
}
function handleModelUrlLoad() {
const url = modelUrl.value.trim();
if (!url) return;
loadModelFromUrl(url);
}
new ResizeObserver(() => resizeStage()).observe(avatarCanvas);
window.addEventListener("resize", resizeStage);
healthBtn.addEventListener("click", checkHealth);
offerBtn.addEventListener("click", connectWebRTC);
resetBtn.addEventListener("click", resetChat);
sendBtn.addEventListener("click", sendText);
loadModelBtn.addEventListener("click", handleModelUrlLoad);
resetViewBtn.addEventListener("click", resetView);
modelFile.addEventListener("change", handleModelFileChange);
chatInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
sendText();
}
});
modelUrl.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
handleModelUrlLoad();
}
});
initStage();
resetView();
resetAvatarPose();
loadDefaultAvatarModel();
checkHealth();
subscribeEvents();
subscribeSubtitles();
subscribeAnimation();
setConnBadge("未连接", "neutral");
tryAutoReconnect();