测试通过

This commit is contained in:
xsl
2026-03-29 23:33:22 +08:00
parent 4dcae11c1b
commit 66c742864c
10 changed files with 504 additions and 56 deletions
+139 -2
View File
@@ -64,6 +64,7 @@ let micProcessorNode = null;
let micSinkGain = null;
let audioPlaybackCursor = 0;
const audioPlaybackSources = new Set();
const recentSubtitleEvents = new Map();
let reconnectOnGestureArmed = false;
let pageGestureBootstrapArmed = false;
@@ -127,6 +128,18 @@ const avatarState = {
debugRig: null,
debugJaw: null,
debugMouth: null,
debugLeftEye: null,
debugRightEye: null,
blink: {
value: 0,
phase: "idle",
phaseStartedAtMs: performance.now(),
nextAtMs: performance.now() + 1800,
closeMs: 90,
holdMs: 50,
openMs: 120,
pendingDoubleBlink: false,
},
};
const gltfLoader = new GLTFLoader();
@@ -160,6 +173,27 @@ function addMessage(role, text) {
return div;
}
function shouldRenderSubtitleEvent(data) {
const key = JSON.stringify({
role: data?.role || "",
text: data?.text || "",
partial: Boolean(data?.partial),
final: Boolean(data?.final),
source: data?.source || "",
});
const now = Date.now();
const lastSeenAt = recentSubtitleEvents.get(key) || 0;
recentSubtitleEvents.set(key, now);
for (const [entryKey, seenAt] of recentSubtitleEvents.entries()) {
if (now - seenAt > 1500) {
recentSubtitleEvents.delete(entryKey);
}
}
return now - lastSeenAt > 350;
}
function setConnBadge(label, kind = "neutral") {
connBadge.textContent = label;
connBadge.className = `badge ${kind}`;
@@ -487,6 +521,8 @@ function createDebugRig() {
avatarState.debugRig = group;
avatarState.debugJaw = jawPivot;
avatarState.debugMouth = mouth;
avatarState.debugLeftEye = leftEye;
avatarState.debugRightEye = rightEye;
avatarState.headBone = neck;
avatarState.neckBone = neck;
}
@@ -505,6 +541,7 @@ function renderLoop() {
const elapsed = CLOCK.getElapsedTime();
controls.update();
flushAnimationQueue();
updateBlinkState(performance.now());
applyProceduralIdlePose(elapsed);
applyAnimationControls(avatarState.activeControls);
avatarState.currentVrm?.update?.(delta);
@@ -618,6 +655,8 @@ function applyAnimationControls(controls) {
setMorphValue("mouthPucker", mouthPucker);
}
applyBlinkControls(avatarState.blink?.value || 0);
// 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;
@@ -702,6 +741,8 @@ function detachCurrentAvatar() {
avatarState.headBone = avatarState.debugRig.children[1];
avatarState.neckBone = avatarState.debugRig.children[1];
avatarState.debugRig.visible = true;
avatarState.debugLeftEye = avatarState.debugLeftEye || avatarState.debugRig.children[1]?.children?.[2] || null;
avatarState.debugRightEye = avatarState.debugRightEye || avatarState.debugRig.children[1]?.children?.[3] || null;
modelName.textContent = "debug-avatar";
}
@@ -831,6 +872,9 @@ function collectMorphBindings(root) {
viseme_ee: ["viseme_ee", "ee", "ih", "i", "mouthee"],
viseme_oh: ["viseme_oh", "oh", "ou", "o", "mouthoh"],
mouthPucker: ["mouthpucker", "pucker", "ou", "kiss"],
blink: ["blink", "eyeblink", "eye_blink", "blinkboth"],
blinkLeft: ["blinkleft", "blink_left", "eyeblinkleft", "eyelid_l", "eyeblink_l"],
blinkRight: ["blinkright", "blink_right", "eyeblinkright", "eyelid_r", "eyeblink_r"],
};
root.traverse((node) => {
@@ -910,6 +954,82 @@ function applyProceduralIdlePose(time) {
applyBoneRotation(rig.bones.rightHand, handDrift * 0.2, 0, 0.05);
}
function scheduleNextBlink(nowMs, preferSoon = false) {
const quickBlink = Math.random() < 0.18;
const minDelay = preferSoon ? 240 : 1800;
const maxDelay = preferSoon ? 520 : 5200;
avatarState.blink.nextAtMs = nowMs + minDelay + Math.random() * (maxDelay - minDelay);
avatarState.blink.pendingDoubleBlink = !preferSoon && quickBlink;
}
function updateBlinkState(nowMs) {
const blink = avatarState.blink;
if (!blink) return;
if (blink.phase === "idle") {
blink.value = 0;
if (nowMs >= blink.nextAtMs) {
blink.phase = "closing";
blink.phaseStartedAtMs = nowMs;
}
return;
}
if (blink.phase === "closing") {
const progress = Math.min(1, (nowMs - blink.phaseStartedAtMs) / blink.closeMs);
blink.value = progress;
if (progress >= 1) {
blink.phase = "hold";
blink.phaseStartedAtMs = nowMs;
blink.value = 1;
}
return;
}
if (blink.phase === "hold") {
blink.value = 1;
if (nowMs - blink.phaseStartedAtMs >= blink.holdMs) {
blink.phase = "opening";
blink.phaseStartedAtMs = nowMs;
}
return;
}
if (blink.phase === "opening") {
const progress = Math.min(1, (nowMs - blink.phaseStartedAtMs) / blink.openMs);
blink.value = 1 - progress;
if (progress >= 1) {
blink.phase = "idle";
blink.phaseStartedAtMs = nowMs;
blink.value = 0;
scheduleNextBlink(nowMs, blink.pendingDoubleBlink);
}
}
}
function applyBlinkControls(blinkValue) {
const clamped = clampControl(blinkValue);
if (avatarState.currentVrm?.expressionManager) {
setExpressionValue("blink", clamped);
setExpressionValue("blinkLeft", clamped);
setExpressionValue("blinkRight", clamped);
}
setMorphValue("blink", clamped);
setMorphValue("blinkLeft", clamped);
setMorphValue("blinkRight", clamped);
const eyeScaleY = Math.max(0.08, 1 - clamped * 0.92);
const eyeScaleZ = 1 + clamped * 0.35;
if (avatarState.debugLeftEye) {
avatarState.debugLeftEye.scale.set(1, eyeScaleY, eyeScaleZ);
}
if (avatarState.debugRightEye) {
avatarState.debugRightEye.scale.set(1, eyeScaleY, eyeScaleZ);
}
}
function applyBoneRotation(bone, x = 0, y = 0, z = 0) {
if (!bone) return;
const baseQuaternion = avatarState.idleRig?.base?.get(bone);
@@ -956,6 +1076,9 @@ function subscribeSubtitles() {
subtitleWs.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (!shouldRenderSubtitleEvent(data)) {
return;
}
if (data.role === "user") {
aiStreamingEl = null;
addMessage(data.role, data.text || "");
@@ -973,7 +1096,14 @@ function subscribeSubtitles() {
// no-op
}
};
subtitleWs.onclose = () => {
subtitleWs.onclose = (event) => {
if (subtitleWs && subtitleWs.readyState === WebSocket.CLOSED) {
subtitleWs = null;
}
if (event.code === 1012) {
addMessage("system", "字幕通道已被新客户端接管。当前页面停止接收字幕。");
return;
}
setTimeout(subscribeSubtitles, 1000);
};
}
@@ -1001,8 +1131,15 @@ function subscribeAnimation() {
// no-op
}
};
animationWs.onclose = () => {
animationWs.onclose = (event) => {
if (animationWs && animationWs.readyState === WebSocket.CLOSED) {
animationWs = null;
}
animationConn.textContent = "已断开";
if (event.code === 1012) {
animationConn.textContent = "已被新客户端接管";
return;
}
setTimeout(subscribeAnimation, 1000);
};
}
+2 -1
View File
@@ -96,6 +96,7 @@
}
}
</script>
<script type="module" src="/web/app.js?v=20260329k"></script>
<script type="module" src="/web/app.js?v=20260329m"></script>
<script type="module" src="/web/app.js?v=20260329q"></script>
</body>
</html>