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
+2 -1
View File
@@ -59,7 +59,8 @@ export class TtsController {
const parsed = JSON.parse(result)
if (parsed.text) return { text: parsed.text.trim() }
}
const whisperResult = execSync(`whisper "${dest}" --language zh --output_format txt 2>/dev/null`, { encoding: 'utf8', timeout: 60000 })
const whisperBin = '/root/.local/bin/whisper'
const whisperResult = execSync(`${whisperBin} "${dest}" --language zh --output_format txt 2>/dev/null`, { encoding: 'utf8', timeout: 60000 })
if (whisperResult && whisperResult.trim()) {
return { text: whisperResult.trim() }
}
+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磁场",
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Bottts</dc:title><dc:creator>Pablo Stanley</dc:creator><dc:source xsi:type="dcterms:URI">https://bottts.com/</dc:source><dcterms:license xsi:type="dcterms:URI">https://bottts.com/</dcterms:license><dc:rights>Remix of „Bottts” (https://bottts.com/) by „Pablo Stanley”, licensed under „Free for personal and commercial use” (https://bottts.com/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="180" height="180" rx="0" ry="0" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#b6e3f4" width="180" height="180" x="0" y="0" /><g transform="translate(0 66)"><path d="M38 12c-2.95 11.7-19.9 6.67-23.37 18-3.46 11.35 8.03 20 17.53 20" stroke="#2A3544" stroke-width="6" opacity=".9"/><path d="M150 55c8.4 3.49 20.1-7.6 16-16.5-4.1-8.9-16-6.7-16-19.3" stroke="#2A3544" stroke-width="4" opacity=".9"/><mask id="sidesCables01-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="21" y="6" width="138" height="58"><g fill="#fff"><rect x="21" y="35" width="16" height="22" rx="2"/><rect x="136" y="42" width="23" height="22" rx="2"/><rect x="136" y="6" width="23" height="18" rx="2"/></g></mask><g mask="url(#sidesCables01-a)"><path d="M0 0h180v76H0V0Z" fill="#546e7a"/><path d="M0 0h180v76H0V0Z" fill="#fff" fill-opacity=".3"/></g></g><g transform="translate(41)"><g filter="url(#topGlowingBulb01-a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M32 24A16 16 0 0 1 48 8h4a16 16 0 0 1 16 16v8a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-8Z" fill="#fff" fill-opacity=".3"/></g><path d="M49 11.5c4.93 0 9.37 2.13 12.44 5.52" stroke="#fff" stroke-width="2" stroke-linecap="round"/><path d="m49.83 29-9-9L38 22.83l10 10V40h4v-7.03l10.14-10.14L59.31 20l-9 9h-.48Z" fill="#fff" fill-opacity=".8"/><rect x="22" y="40" width="56" height="12" rx="1" fill="#48494B"/><defs><filter id="topGlowingBulb01-a" x="24" y="0" width="52" height="48" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset/><feGaussianBlur stdDeviation="4"/><feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_617_621"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_617_621" result="shape"/><feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset/><feGaussianBlur stdDeviation="2"/><feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/><feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0"/><feBlend in2="shape" result="effect2_innerShadow_617_621"/></filter></defs></g><g transform="translate(25 44)"><mask id="faceRound01-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="130" height="120"><path fill-rule="evenodd" clip-rule="evenodd" d="M66 0c58.35 0 64 40.69 64 78 0 33.31-25.47 42-64 42-37.46 0-66-8.69-66-42C0 40.69 7.65 0 66 0Z" fill="#fff"/></mask><g mask="url(#faceRound01-a)"><path d="M-4-2h138v124H-4V-2Z" fill="#546e7a"/><g transform="translate(-1 -1)"></g></g></g><g transform="translate(52 124)"><g fill="#000" fill-opacity=".6"><rect x="12" y="12" width="4" height="8" rx="2"/><rect x="36" y="12" width="4" height="8" rx="2"/><rect x="24" y="12" width="4" height="8" rx="2"/><rect x="48" y="12" width="4" height="8" rx="2"/><rect x="60" y="12" width="4" height="8" rx="2"/></g></g><g transform="translate(38 76)"><rect y="11" width="104" height="34" rx="17" fill="#000" fill-opacity=".8"/><circle cx="29" cy="28" r="13" fill="#F1EEDA"/><circle cx="75" cy="28" r="13" fill="#F1EEDA"/><rect x="24" y="23" width="10" height="10" rx="2" fill="#000" fill-opacity=".8"/><rect x="70" y="23" width="10" height="10" rx="2" fill="#000" fill-opacity=".8"/></g></g></svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+83
View File
@@ -0,0 +1,83 @@
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 400 400">
<defs>
<radialGradient id="skinGrad" cx="50%" cy="40%">
<stop offset="0%" stop-color="#FDE8D0"/>
<stop offset="100%" stop-color="#F0D0A8"/>
</radialGradient>
<radialGradient id="hairGrad" cx="50%" cy="80%">
<stop offset="0%" stop-color="#4A3728"/>
<stop offset="100%" stop-color="#2D1B1E"/>
</radialGradient>
<radialGradient id="eyeWhite" cx="50%" cy="40%">
<stop offset="0%" stop-color="#FFFFFF"/>
<stop offset="100%" stop-color="#F0F0F0"/>
</radialGradient>
<radialGradient id="irisGrad" cx="40%" cy="35%">
<stop offset="0%" stop-color="#6B4E37"/>
<stop offset="70%" stop-color="#3D2B1F"/>
<stop offset="100%" stop-color="#1A1108"/>
</radialGradient>
<linearGradient id="shirtGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#4B5563"/>
<stop offset="100%" stop-color="#374151"/>
</linearGradient>
</defs>
<!-- Background -->
<circle cx="200" cy="200" r="200" fill="#F0F2F5"/>
<!-- Hair back -->
<ellipse cx="200" cy="120" rx="120" ry="100" fill="url(#hairGrad)"/>
<!-- Neck -->
<rect x="165" y="270" width="70" height="50" rx="10" fill="url(#skinGrad)"/>
<!-- Shirt -->
<ellipse cx="200" cy="350" rx="140" ry="80" fill="url(#shirtGrad)"/>
<path d="M165 300 L200 340 L235 300" fill="none" stroke="#FFFFFF" stroke-width="2" opacity="0.3"/>
<!-- Face -->
<ellipse cx="200" cy="195" rx="100" ry="120" fill="url(#skinGrad)"/>
<!-- Hair front -->
<path d="M100 160 Q100 80 200 70 Q300 80 300 160 Q280 130 200 125 Q120 130 100 160 Z" fill="url(#hairGrad)"/>
<path d="M95 170 Q90 130 120 110 Q110 160 95 170 Z" fill="url(#hairGrad)"/>
<path d="M305 170 Q310 130 280 110 Q290 160 305 170 Z" fill="url(#hairGrad)"/>
<!-- Left eyebrow -->
<path d="M135 155 Q150 145 170 148" fill="none" stroke="#3D2B1F" stroke-width="3" stroke-linecap="round"/>
<!-- Right eyebrow -->
<path d="M265 155 Q250 145 230 148" fill="none" stroke="#3D2B1F" stroke-width="3" stroke-linecap="round"/>
<!-- Left eye -->
<ellipse cx="158" cy="175" rx="24" ry="18" fill="url(#eyeWhite)" stroke="#D4C4B0" stroke-width="1"/>
<circle cx="158" cy="175" r="12" fill="url(#irisGrad)"/>
<circle cx="158" cy="175" r="6" fill="#0A0A0A"/>
<circle cx="153" cy="170" r="4" fill="#FFFFFF" opacity="0.8"/>
<circle cx="163" cy="178" r="2" fill="#FFFFFF" opacity="0.5"/>
<!-- Right eye -->
<ellipse cx="242" cy="175" rx="24" ry="18" fill="url(#eyeWhite)" stroke="#D4C4B0" stroke-width="1"/>
<circle cx="242" cy="175" r="12" fill="url(#irisGrad)"/>
<circle cx="242" cy="175" r="6" fill="#0A0A0A"/>
<circle cx="237" cy="170" r="4" fill="#FFFFFF" opacity="0.8"/>
<circle cx="247" cy="178" r="2" fill="#FFFFFF" opacity="0.5"/>
<!-- Nose -->
<path d="M195 195 Q190 220 185 228 Q195 232 200 232 Q205 232 215 228 Q210 220 205 195" fill="none" stroke="#D4B896" stroke-width="1.5" stroke-linecap="round"/>
<ellipse cx="200" cy="230" rx="6" ry="3" fill="#E8C8A8" opacity="0.6"/>
<!-- Mouth -->
<path d="M178 260 Q189 268 200 268 Q211 268 222 260" fill="none" stroke="#C97B84" stroke-width="2.5" stroke-linecap="round"/>
<!-- Cheek blush -->
<ellipse cx="125" cy="225" rx="22" ry="14" fill="#FFB0A0" opacity="0.25"/>
<ellipse cx="275" cy="225" rx="22" ry="14" fill="#FFB0A0" opacity="0.25"/>
<!-- Ear left -->
<ellipse cx="100" cy="185" rx="12" ry="20" fill="url(#skinGrad)"/>
<!-- Ear right -->
<ellipse cx="300" cy="185" rx="12" ry="20" fill="url(#skinGrad)"/>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB