292 lines
6.2 KiB
Vue
292 lines
6.2 KiB
Vue
<template>
|
|
<view class="digital-human">
|
|
<view class="avatar-stage">
|
|
<view class="avatar-ring" :class="{ speaking: isSpeaking }">
|
|
<!-- Default CSS avatar if image fails -->
|
|
<view class="avatar-default" v-if="imgFailed">
|
|
<text class="avatar-initials">AI</text>
|
|
</view>
|
|
<image class="avatar-img" :src="avatarSrc" mode="aspectFill" @error="imgFailed = true" v-else />
|
|
<canvas
|
|
v-if="isH5"
|
|
id="dh-mouth"
|
|
class="mouth-canvas"
|
|
></canvas>
|
|
</view>
|
|
<text class="role-label">AI 面试官</text>
|
|
</view>
|
|
|
|
<view class="speech-area" v-if="currentText">
|
|
<view class="speech-bubble">
|
|
<text class="speech-text">{{ currentText }}</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
|
|
|
const props = defineProps({
|
|
text: { type: String, default: '' },
|
|
audioUrl: { type: String, default: '' },
|
|
avatarUrl: { type: String, default: '' },
|
|
autoPlay: { type: Boolean, default: true },
|
|
})
|
|
|
|
const emit = defineEmits(['speaking-start', 'speaking-end'])
|
|
|
|
const isH5 = ref(false)
|
|
const isSpeaking = ref(false)
|
|
const currentText = ref('')
|
|
const imgFailed = ref(false)
|
|
let audioEl = null
|
|
let audioCtx = null
|
|
let analyser = null
|
|
let animFrameId = null
|
|
let mouthScale = 0
|
|
|
|
onMounted(() => {
|
|
isH5.value = typeof window !== 'undefined' && typeof document !== 'undefined'
|
|
initCanvas()
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
stopAudio()
|
|
})
|
|
|
|
watch(() => props.audioUrl, (url) => {
|
|
if (url && props.autoPlay) {
|
|
nextTick(() => playAudio(url))
|
|
}
|
|
})
|
|
|
|
watch(() => props.text, (txt) => {
|
|
currentText.value = txt
|
|
})
|
|
|
|
const avatarSrc = computed(() => {
|
|
return props.avatarUrl || '/static/default-avatar.png'
|
|
})
|
|
|
|
function initCanvas() {
|
|
if (!isH5.value) return
|
|
const canvas = document.getElementById('dh-mouth')
|
|
if (!canvas) return
|
|
// Size the canvas to match mouth area (~40% width, ~15% height, centered bottom)
|
|
const parent = canvas.parentElement
|
|
if (!parent) return
|
|
const rect = parent.getBoundingClientRect()
|
|
canvas.width = rect.width * 0.4
|
|
canvas.height = rect.height * 0.15
|
|
canvas.style.width = canvas.width + 'px'
|
|
canvas.style.height = canvas.height + 'px'
|
|
canvas.style.position = 'absolute'
|
|
canvas.style.bottom = '18%'
|
|
canvas.style.left = '30%'
|
|
}
|
|
|
|
function drawMouth(openRatio) {
|
|
if (!isH5.value) return
|
|
const canvas = document.getElementById('dh-mouth')
|
|
if (!canvas) return
|
|
const ctx = canvas.getContext('2d')
|
|
if (!ctx) return
|
|
|
|
const w = canvas.width
|
|
const h = canvas.height
|
|
ctx.clearRect(0, 0, w, h)
|
|
|
|
const mouthH = Math.max(2, h * openRatio)
|
|
const mouthW = w * 0.8
|
|
|
|
ctx.fillStyle = '#C97B84'
|
|
ctx.beginPath()
|
|
ctx.ellipse(w / 2, h / 2 + (h - mouthH) / 2, mouthW / 2, mouthH / 2, 0, 0, Math.PI * 2)
|
|
ctx.fill()
|
|
|
|
if (openRatio > 0.1) {
|
|
ctx.fillStyle = '#2D1B1E'
|
|
ctx.beginPath()
|
|
ctx.ellipse(w / 2, h / 2 + (h - mouthH) / 2 + 1, mouthW / 4, mouthH / 4, 0, 0, Math.PI * 2)
|
|
ctx.fill()
|
|
}
|
|
}
|
|
|
|
async function playAudio(url) {
|
|
stopAudio()
|
|
isSpeaking.value = true
|
|
emit('speaking-start')
|
|
|
|
try {
|
|
audioEl = new Audio(url)
|
|
|
|
if (isH5.value) {
|
|
audioCtx = new (window.AudioContext || window.webkitAudioContext)()
|
|
const source = audioCtx.createMediaElementSource(audioEl)
|
|
analyser = audioCtx.createAnalyser()
|
|
analyser.fftSize = 256
|
|
source.connect(analyser)
|
|
analyser.connect(audioCtx.destination)
|
|
}
|
|
|
|
audioEl.onended = () => {
|
|
finishSpeaking()
|
|
}
|
|
|
|
audioEl.onerror = () => {
|
|
finishSpeaking()
|
|
}
|
|
|
|
await audioEl.play()
|
|
|
|
if (analyser) {
|
|
animateMouth()
|
|
}
|
|
} catch (e) {
|
|
finishSpeaking()
|
|
}
|
|
}
|
|
|
|
function animateMouth() {
|
|
if (!analyser) return
|
|
const dataArray = new Uint8Array(analyser.frequencyBinCount)
|
|
|
|
function tick() {
|
|
if (!isSpeaking.value) return
|
|
analyser.getByteFrequencyData(dataArray)
|
|
const sum = dataArray.reduce((a, b) => a + b, 0)
|
|
const avg = sum / dataArray.length
|
|
mouthScale = Math.min(1, avg / 128)
|
|
// Smooth
|
|
mouthScale = Math.max(0.05, mouthScale)
|
|
drawMouth(mouthScale)
|
|
animFrameId = requestAnimationFrame(tick)
|
|
}
|
|
|
|
tick()
|
|
}
|
|
|
|
function finishSpeaking() {
|
|
isSpeaking.value = false
|
|
emit('speaking-end')
|
|
if (analyser) {
|
|
mouthScale = 0
|
|
drawMouth(0)
|
|
}
|
|
cleanupAudio()
|
|
}
|
|
|
|
function cleanupAudio() {
|
|
if (animFrameId) { cancelAnimationFrame(animFrameId); animFrameId = null }
|
|
if (audioCtx) { audioCtx.close().catch(() => {}); audioCtx = null }
|
|
analyser = null
|
|
audioEl = null
|
|
}
|
|
|
|
function stopAudio() {
|
|
if (audioEl) {
|
|
try { audioEl.pause(); audioEl.src = '' } catch {}
|
|
}
|
|
finishSpeaking()
|
|
}
|
|
|
|
defineExpose({ play: playAudio, stop: stopAudio })
|
|
</script>
|
|
|
|
<style scoped>
|
|
.digital-human {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
padding: 20rpx 0;
|
|
}
|
|
|
|
/* Avatar stage */
|
|
.avatar-stage {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 12rpx;
|
|
}
|
|
|
|
.avatar-ring {
|
|
width: 200rpx;
|
|
height: 200rpx;
|
|
border-radius: 50%;
|
|
border: 4rpx solid #E5E7EB;
|
|
overflow: hidden;
|
|
position: relative;
|
|
transition: border-color 0.3s, box-shadow 0.3s;
|
|
}
|
|
|
|
.avatar-ring.speaking {
|
|
border-color: #6366F1;
|
|
box-shadow: 0 0 30rpx rgba(99, 102, 241, 0.3);
|
|
}
|
|
|
|
.avatar-img {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.avatar-default {
|
|
width: 100%;
|
|
height: 100%;
|
|
background: linear-gradient(135deg, #6366F1, #8B5CF6);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.avatar-initials {
|
|
font-size: 48rpx;
|
|
font-weight: 700;
|
|
color: #FFFFFF;
|
|
}
|
|
|
|
.mouth-canvas {
|
|
pointer-events: none;
|
|
}
|
|
|
|
.role-label {
|
|
font-size: 22rpx;
|
|
color: #6B7280;
|
|
background: #F3F4F6;
|
|
padding: 4rpx 20rpx;
|
|
border-radius: 20rpx;
|
|
}
|
|
|
|
/* Speech bubble */
|
|
.speech-area {
|
|
margin-top: 24rpx;
|
|
padding: 0 40rpx;
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.speech-bubble {
|
|
background: #FFFFFF;
|
|
border-radius: 16rpx;
|
|
padding: 24rpx 28rpx;
|
|
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
|
|
position: relative;
|
|
}
|
|
|
|
.speech-bubble::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: -12rpx;
|
|
left: 60rpx;
|
|
border: 12rpx solid transparent;
|
|
border-bottom-color: #FFFFFF;
|
|
border-top: none;
|
|
}
|
|
|
|
.speech-text {
|
|
font-size: 28rpx;
|
|
line-height: 1.7;
|
|
color: #1F2937;
|
|
}
|
|
</style>
|