测试通过
This commit is contained in:
+139
-2
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user