fix: PNG face avatar + whisper ASR

This commit is contained in:
yuzhiran
2026-06-13 11:04:52 +08:00
parent 93ab79d200
commit 112884a504
6 changed files with 120 additions and 183 deletions
+32 -180
View File
@@ -2,24 +2,8 @@
<view class="digital-human">
<view class="avatar-stage">
<view class="avatar-ring" :class="{ speaking: isSpeaking }">
<view class="avatar-css">
<view class="face-css">
<view class="face-skin">
<view class="eye eye-left" :class="{ blink: isBlinking }"></view>
<view class="eye eye-right" :class="{ blink: isBlinking }"></view>
<view class="eyebrow eyebrow-left"></view>
<view class="eyebrow eyebrow-right"></view>
<view class="nose"></view>
<view class="mouth" :class="{ speaking: isSpeaking }">
<view class="mouth-inner" :style="mouthStyle"></view>
</view>
<view class="blush blush-left"></view>
<view class="blush blush-right"></view>
</view>
<view class="hair"></view>
</view>
</view>
<canvas v-if="isH5" id="dh-mouth" class="mouth-canvas"></canvas>
<image class="face-img" src="/static/ai-face.png" mode="aspectFill" />
<view class="mouth-overlay" :class="{ open: isSpeaking }" :style="{ height: mouthHeight + 'rpx' }"></view>
</view>
<view class="status-dot" :class="{ active: isSpeaking }"></view>
<text class="role-label">AI 面试官</text>
@@ -33,7 +17,7 @@
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
const props = defineProps({
text: { type: String, default: '' },
@@ -44,30 +28,21 @@ const props = defineProps({
const emit = defineEmits(['speaking-start', 'speaking-end'])
const isH5 = ref(false)
const isSpeaking = ref(false)
const isBlinking = ref(false)
const currentText = ref('')
const mouthOpenRatio = ref(0)
const mouthHeight = ref(4)
let audioEl = null
let mouthTimer = null
let blinkTimer = null
let mouthAnimTimer = null
const mouthStyle = computed(() => ({
height: (2 + mouthOpenRatio.value * 14) + 'rpx',
width: (12 + mouthOpenRatio.value * 8) + 'rpx',
}))
onMounted(() => {
isH5.value = typeof window !== 'undefined' && typeof document !== 'undefined'
scheduleBlink()
})
onBeforeUnmount(() => {
stopAudio()
if (blinkTimer) clearTimeout(blinkTimer)
if (mouthAnimTimer) clearInterval(mouthAnimTimer)
})
watch(() => props.audioUrl, (url) => {
@@ -81,10 +56,12 @@ watch(() => props.text, (txt) => {
})
function scheduleBlink() {
const delay = 2000 + Math.random() * 3000
const delay = 2500 + Math.random() * 3500
blinkTimer = setTimeout(() => {
isBlinking.value = true
setTimeout(() => { isBlinking.value = false }, 150)
if (isSpeaking.value) {
mouthHeight.value = 14
setTimeout(() => { mouthHeight.value = 4 }, 100)
}
scheduleBlink()
}, delay)
}
@@ -109,19 +86,20 @@ function playAudio(url) {
}
function startMouthAnim() {
let direction = 1
mouthAnimTimer = setInterval(() => {
const step = mouthOpenRatio.value + direction * 0.08
if (step >= 0.8) direction = -1
else if (step <= 0.1) direction = 1
mouthOpenRatio.value = Math.max(0.05, Math.min(1, step))
}, 80)
let dir = 1
let h = 4
mouthTimer = setInterval(() => {
h += dir * 2
if (h >= 16) dir = -1
else if (h <= 4) dir = 1
mouthHeight.value = h
}, 100)
}
function finishSpeaking() {
isSpeaking.value = false
mouthOpenRatio.value = 0
if (mouthAnimTimer) { clearInterval(mouthAnimTimer); mouthAnimTimer = null }
mouthHeight.value = 4
if (mouthTimer) { clearInterval(mouthTimer); mouthTimer = null }
cleanupAudio()
emit('speaking-end')
}
@@ -167,154 +145,29 @@ defineExpose({ play: playAudio, stop: stopAudio })
.avatar-ring.speaking {
border-color: #6366F1;
box-shadow: 0 0 40rpx rgba(99, 102, 241, 0.4);
animation: speakPulse 1.5s ease-in-out infinite;
}
/* CSS Face */
.face-css {
@keyframes speakPulse {
0%, 100% { box-shadow: 0 0 20rpx rgba(99, 102, 241, 0.3); }
50% { box-shadow: 0 0 50rpx rgba(99, 102, 241, 0.6); }
}
.face-img {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.hair {
.mouth-overlay {
position: absolute;
top: -8%;
left: -6%;
width: 112%;
height: 48%;
background: radial-gradient(ellipse at 50% 100%, #3D2B1F 60%, transparent 72%);
z-index: 2;
border-radius: 50% 50% 0 0;
}
.face-skin {
position: absolute;
inset: 6%;
background: radial-gradient(ellipse at 50% 40%, #FDE8D0, #F5D5B0);
border-radius: 50%;
z-index: 1;
}
/* Eyes */
.eye {
position: absolute;
width: 20%;
height: 18%;
background: #FFFFFF;
border-radius: 50%;
top: 38%;
z-index: 3;
border: 2rpx solid #8B7355;
overflow: hidden;
}
.eye::after {
content: '';
position: absolute;
width: 55%;
height: 55%;
background: radial-gradient(circle, #5B4033 30%, #1A1A1A 70%);
border-radius: 50%;
top: 22%;
left: 22%;
}
.eye::before {
content: '';
position: absolute;
width: 20%;
height: 20%;
background: #FFFFFF;
border-radius: 50%;
top: 18%;
left: 28%;
z-index: 4;
}
.eye-left { left: 18%; }
.eye-right { right: 18%; }
.eye.blink::after { display: none; }
.eye.blink::before { display: none; }
.eye.blink {
background: #F5D5B0;
border-color: #F5D5B0;
height: 3%;
top: 44%;
overflow: visible;
}
/* Eyebrows */
.eyebrow {
position: absolute;
width: 22%;
height: 4rpx;
background: #3D2B1F;
border-radius: 4rpx;
top: 32%;
z-index: 3;
}
.eyebrow-left { left: 17%; transform: rotate(-6deg); }
.eyebrow-right { right: 17%; transform: rotate(6deg); }
/* Nose */
.nose {
position: absolute;
width: 8%;
height: 12%;
top: 46%;
left: 46%;
z-index: 3;
border-left: 2rpx solid #E8C8A8;
border-right: 2rpx solid #E8C8A8;
border-radius: 0 0 50% 50%;
}
/* Mouth */
.mouth {
position: absolute;
bottom: 18%;
bottom: 28%;
left: 50%;
transform: translateX(-50%);
width: 28%;
height: 12%;
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.1s;
}
.mouth-inner {
width: 22%;
background: #C97B84;
border-radius: 50%;
transition: all 0.08s ease;
transition: height 0.1s;
}
.mouth.speaking .mouth-inner {
background: #B06570;
box-shadow: inset 0 0 6rpx rgba(0,0,0,0.2);
.mouth-overlay.open {
background: #A85562;
}
/* Blush */
.blush {
position: absolute;
width: 16%;
height: 10%;
border-radius: 50%;
background: rgba(255, 150, 150, 0.35);
top: 54%;
z-index: 2;
}
.blush-left { left: 8%; }
.blush-right { right: 8%; }
/* Neck & body suggestion */
.avatar-ring::after {
content: '';
position: absolute;
bottom: -4%;
left: 50%;
transform: translateX(-50%);
width: 30%;
height: 12%;
background: #F5D5B0;
border-radius: 0 0 50% 50%;
}
.status-dot {
width: 14rpx;
height: 14rpx;
@@ -336,7 +189,6 @@ defineExpose({ play: playAudio, stop: stopAudio })
padding: 4rpx 20rpx;
border-radius: 20rpx;
}
.mouth-canvas { display: none; }
.speech-area {
margin-top: 12rpx;
padding: 0 40rpx;
+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "宇之然AI磁场",
"appid": "__UNI__DEV__",
"versionName": "1.0.8",
"versionCode": "108",
"versionName": "1.0.9",
"versionCode": "109",
"description": "职引 - 宇之然AI磁场旗下AI模拟面试平台,提供AI面试官模拟练习、简历智能优化、大厂面经题库,助你轻松应对校招面试。",
"h5": {
"title": "职引 - AI模拟面试 | 宇之然AI磁场",