feat: basic 3d emotion from LLM completions
This commit is contained in:
@@ -11,7 +11,7 @@ import { useMarkdown } from '../composables/markdown'
|
||||
import { useQueue } from '../composables/queue'
|
||||
import { useDelayMessageQueue, useEmotionsMessageQueue, useMessageContentQueue } from '../composables/queues'
|
||||
import { llmInferenceEndToken } from '../constants'
|
||||
import { EMOTION_EmotionMotionName_value, EmotionThinkMotionName } from '../constants/emotions'
|
||||
import { EMOTION_EmotionMotionName_value, EMOTION_VRMExpressionName_value, EmotionThinkMotionName } from '../constants/emotions'
|
||||
import SystemPromptV2 from '../constants/prompts/system-v2'
|
||||
import { useLLM } from '../stores/llm'
|
||||
import { useSettings } from '../stores/settings'
|
||||
@@ -25,9 +25,13 @@ import ThreeDScene from './ThreeDScene.vue'
|
||||
const nowSpeakingAvatarBorderOpacityMin = 30
|
||||
const nowSpeakingAvatarBorderOpacityMax = 100
|
||||
|
||||
const { elevenLabsApiKey, openAiApiBaseURL, openAiApiKey } = storeToRefs(useSettings())
|
||||
const {
|
||||
elevenLabsApiKey,
|
||||
openAiApiBaseURL,
|
||||
openAiApiKey,
|
||||
stageView,
|
||||
} = storeToRefs(useSettings())
|
||||
const openAIModel = useLocalStorage<{ id: string, name?: string }>('settings/llm/openai/model', { id: 'openai/gpt-3.5-turbo', name: 'OpenAI GPT3.5 Turbo' })
|
||||
const stageView = useLocalStorage('settings/stage/view/model-renderer', '2d')
|
||||
|
||||
const {
|
||||
streamSpeech,
|
||||
@@ -39,6 +43,7 @@ const { process } = useMarkdown()
|
||||
|
||||
const listening = ref(false)
|
||||
const live2DViewerRef = ref<{ setMotion: (motionName: string) => Promise<void> }>()
|
||||
const vrmViewerRef = ref<{ setExpression: (expression: string) => void }>()
|
||||
const supportedModels = ref<{ id: string, name?: string }[]>([])
|
||||
const messageInput = ref<string>('')
|
||||
const messages = ref<Array<Message>>([SystemPromptV2 as SystemMessage])
|
||||
@@ -131,7 +136,16 @@ const messageContentQueue = useMessageContentQueue(ttsQueue)
|
||||
const emotionsQueue = useQueue<Emotion>({
|
||||
handlers: [
|
||||
async (ctx) => {
|
||||
await live2DViewerRef.value!.setMotion(EMOTION_EmotionMotionName_value[ctx.data])
|
||||
if (stageView.value === '3d') {
|
||||
const value = EMOTION_VRMExpressionName_value[ctx.data]
|
||||
if (!value)
|
||||
return
|
||||
|
||||
await vrmViewerRef.value!.setExpression(value)
|
||||
}
|
||||
else if (stageView.value === '2d') {
|
||||
await live2DViewerRef.value!.setMotion(EMOTION_EmotionMotionName_value[ctx.data])
|
||||
}
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -289,6 +303,7 @@ onUnmounted(() => {
|
||||
/>
|
||||
<ThreeDScene
|
||||
v-else-if="stageView === '3d'"
|
||||
ref="vrmViewerRef"
|
||||
model="/assets/vrm/models/AvatarSample-B/AvatarSample_B.vrm"
|
||||
idle-animation="/assets/vrm/animations/idle_loop.vrma"
|
||||
w="50%" min-w="50% <lg:full" min-h="100 sm:100" h-full flex-1
|
||||
|
||||
@@ -22,84 +22,128 @@ const cameraPositionZ = ref(-1)
|
||||
const vrmModelPositionX = ref(-0.18)
|
||||
const vrmModelPositionY = ref(-1.4)
|
||||
const vrmModelPositionZ = ref(-0.24)
|
||||
|
||||
const modelRef = ref<{
|
||||
setExpression: (expression: string) => void
|
||||
}>()
|
||||
|
||||
defineExpose({
|
||||
setExpression: (expression: string) => {
|
||||
modelRef.value?.setExpression(expression)
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Screen v-slot="{ canvasHeight, canvasWidth }" relative>
|
||||
<div z="10" top="2" absolute w-full gap-2 flex="~ col md:row">
|
||||
<Collapsable h-fit w-full>
|
||||
<template #label>
|
||||
<span font-mono>Camera</span>
|
||||
</template>
|
||||
<div grid="~ cols-[20px_1fr_60px]" w-full gap-1 p-2 text-sm font-mono>
|
||||
<div text="zinc-400 dark:zinc-500">
|
||||
<span>X</span>
|
||||
</div>
|
||||
<label w-full flex items-center gap-2>
|
||||
<DataGuiRange v-model="cameraPositionX" :min="-10" :max="10" :step="0.01" />
|
||||
</label>
|
||||
<div text-right>
|
||||
<span>{{ cameraPositionX }}</span>
|
||||
</div>
|
||||
<div z="10" top="2" absolute w-full flex="~ col" gap-2>
|
||||
<div w-full flex="~ col md:row" gap-2>
|
||||
<Collapsable h-fit w-full>
|
||||
<template #label>
|
||||
<span font-mono>Camera</span>
|
||||
</template>
|
||||
<div grid="~ cols-[20px_1fr_60px]" w-full gap-1 p-2 text-sm font-mono>
|
||||
<div text="zinc-400 dark:zinc-500">
|
||||
<span>X</span>
|
||||
</div>
|
||||
<label w-full flex items-center gap-2>
|
||||
<DataGuiRange v-model="cameraPositionX" :min="-10" :max="10" :step="0.01" />
|
||||
</label>
|
||||
<div text-right>
|
||||
<span>{{ cameraPositionX }}</span>
|
||||
</div>
|
||||
|
||||
<div text="zinc-400 dark:zinc-500">
|
||||
<span>Y</span>
|
||||
</div>
|
||||
<label w-full flex items-center gap-2>
|
||||
<DataGuiRange v-model="cameraPositionY" :min="-10" :max="10" :step="0.01" />
|
||||
</label>
|
||||
<div text-right>
|
||||
<span>{{ cameraPositionY }}</span>
|
||||
</div>
|
||||
<div text="zinc-400 dark:zinc-500">
|
||||
<span>Y</span>
|
||||
</div>
|
||||
<label w-full flex items-center gap-2>
|
||||
<DataGuiRange v-model="cameraPositionY" :min="-10" :max="10" :step="0.01" />
|
||||
</label>
|
||||
<div text-right>
|
||||
<span>{{ cameraPositionY }}</span>
|
||||
</div>
|
||||
|
||||
<div text="zinc-400 dark:zinc-500">
|
||||
<span>Z</span>
|
||||
</div>
|
||||
<label w-full flex items-center gap-2>
|
||||
<DataGuiRange v-model="cameraPositionZ" :min="-10" :max="10" :step="0.01" />
|
||||
</label>
|
||||
<div text-right>
|
||||
<span>{{ cameraPositionZ }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsable>
|
||||
<Collapsable h-fit w-full>
|
||||
<template #label>
|
||||
<span font-mono>Model</span>
|
||||
</template>
|
||||
<div grid="~ cols-[20px_1fr_60px]" w-full gap-1 p-2 text-sm font-mono>
|
||||
<div text="zinc-400 dark:zinc-500">
|
||||
<span>X</span>
|
||||
<div text="zinc-400 dark:zinc-500">
|
||||
<span>Z</span>
|
||||
</div>
|
||||
<label w-full flex items-center gap-2>
|
||||
<DataGuiRange v-model="cameraPositionZ" :min="-10" :max="10" :step="0.01" />
|
||||
</label>
|
||||
<div text-right>
|
||||
<span>{{ cameraPositionZ }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsable>
|
||||
<Collapsable h-fit w-full>
|
||||
<template #label>
|
||||
<span font-mono>Model</span>
|
||||
</template>
|
||||
<div grid="~ cols-[20px_1fr_60px]" w-full gap-1 p-2 text-sm font-mono>
|
||||
<div text="zinc-400 dark:zinc-500">
|
||||
<span>X</span>
|
||||
</div>
|
||||
|
||||
<label w-full flex items-center gap-2>
|
||||
<DataGuiRange v-model="vrmModelPositionX" :min="-10" :max="10" :step="0.01" />
|
||||
</label>
|
||||
<div text-right>
|
||||
<span>{{ vrmModelPositionX }}</span>
|
||||
</div>
|
||||
<label w-full flex items-center gap-2>
|
||||
<DataGuiRange v-model="vrmModelPositionX" :min="-10" :max="10" :step="0.01" />
|
||||
</label>
|
||||
<div text-right>
|
||||
<span>{{ vrmModelPositionX }}</span>
|
||||
</div>
|
||||
|
||||
<div text="zinc-400 dark:zinc-500">
|
||||
<span>Y</span>
|
||||
</div>
|
||||
<label w-full flex items-center gap-2>
|
||||
<DataGuiRange v-model="vrmModelPositionY" :min="-10" :max="10" :step="0.01" />
|
||||
</label>
|
||||
<div text-right>
|
||||
<span>{{ vrmModelPositionY }}</span>
|
||||
</div>
|
||||
<div text="zinc-400 dark:zinc-500">
|
||||
<span>Y</span>
|
||||
</div>
|
||||
<label w-full flex items-center gap-2>
|
||||
<DataGuiRange v-model="vrmModelPositionY" :min="-10" :max="10" :step="0.01" />
|
||||
</label>
|
||||
<div text-right>
|
||||
<span>{{ vrmModelPositionY }}</span>
|
||||
</div>
|
||||
|
||||
<div text="zinc-400 dark:zinc-500">
|
||||
<span>Z</span>
|
||||
<div text="zinc-400 dark:zinc-500">
|
||||
<span>Z</span>
|
||||
</div>
|
||||
<label w-full flex items-center gap-2>
|
||||
<DataGuiRange v-model="vrmModelPositionZ" :min="-10" :max="10" :step="0.01" />
|
||||
</label>
|
||||
<div text-right>
|
||||
<span>{{ vrmModelPositionZ }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<label w-full flex items-center gap-2>
|
||||
<DataGuiRange v-model="vrmModelPositionZ" :min="-10" :max="10" :step="0.01" />
|
||||
</label>
|
||||
<div text-right>
|
||||
<span>{{ vrmModelPositionZ }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsable>
|
||||
</Collapsable>
|
||||
</div>
|
||||
<div flex="~ row" w-full gap-2>
|
||||
<button
|
||||
rounded-lg bg="zinc-200 dark:zinc-800/50" px-2 py-1
|
||||
@click="modelRef?.setExpression('neutral')"
|
||||
>
|
||||
🙂 Neutral
|
||||
</button>
|
||||
<button
|
||||
rounded-lg bg="zinc-200 dark:zinc-800/50" px-2 py-1
|
||||
@click="modelRef?.setExpression('surprised')"
|
||||
>
|
||||
🤯 Surprised
|
||||
</button>
|
||||
<button
|
||||
rounded-lg bg="zinc-200 dark:zinc-800/50" px-2 py-1
|
||||
@click="modelRef?.setExpression('sad')"
|
||||
>
|
||||
😫 Sad
|
||||
</button>
|
||||
<button
|
||||
rounded-lg bg="zinc-200 dark:zinc-800/50" px-2 py-1
|
||||
@click="modelRef?.setExpression('angry')"
|
||||
>
|
||||
😠 Angry
|
||||
</button>
|
||||
<button
|
||||
rounded-lg bg="zinc-200 dark:zinc-800/50" px-2 py-1
|
||||
@click="modelRef?.setExpression('happy')"
|
||||
>
|
||||
😄 Happy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<TresCanvas :alpha="true" :antialias="true" :width="canvasWidth" :height="canvasHeight">
|
||||
<OrbitControls />
|
||||
@@ -107,6 +151,7 @@ const vrmModelPositionZ = ref(-0.24)
|
||||
<TresDirectionalLight :color="0xFFFFFF" :intensity="1.2" :position="[1, 1, 1]" />
|
||||
<TresAmbientLight :color="0xFFFFFF" :intensity="1.5" />
|
||||
<VRMModel
|
||||
ref="modelRef"
|
||||
:model="props.model"
|
||||
:idle-animation="props.idleAnimation"
|
||||
:position="[vrmModelPositionX, vrmModelPositionY, vrmModelPositionZ]"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useLoop, useTresContext } from '@tresjs/core'
|
||||
import { AnimationMixer } from 'three'
|
||||
import { clipFromVRMAnimation, loadVRMAnimation, useBlink } from '~/composables/vrm/animation'
|
||||
import { loadVrm } from '~/composables/vrm/core'
|
||||
import { useVRMEmote } from '~/composables/vrm/expression'
|
||||
|
||||
const props = defineProps<{
|
||||
model: string
|
||||
@@ -22,6 +23,7 @@ const vrmAnimationMixer = ref<AnimationMixer>()
|
||||
const { scene } = useTresContext()
|
||||
const { onBeforeRender } = useLoop()
|
||||
const blink = useBlink()
|
||||
const vrmEmote = ref<ReturnType<typeof useVRMEmote>>()
|
||||
|
||||
watch(() => props.position, ([x, y, z]) => {
|
||||
if (vrm.value) {
|
||||
@@ -57,10 +59,13 @@ onMounted(async () => {
|
||||
vrmAnimationMixer.value = new AnimationMixer(_vrm.scene)
|
||||
vrmAnimationMixer.value.clipAction(clip).play()
|
||||
|
||||
vrmEmote.value = useVRMEmote(_vrm)
|
||||
|
||||
onBeforeRender(({ delta }) => {
|
||||
vrmAnimationMixer.value?.update(delta)
|
||||
vrm.value?.update(delta)
|
||||
blink.update(vrm.value, delta)
|
||||
vrmEmote.value?.update(delta)
|
||||
})
|
||||
|
||||
vrm.value = _vrm
|
||||
@@ -76,6 +81,12 @@ onUnmounted(() => {
|
||||
scene.value.remove(vrm.value.scene)
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
setExpression(expression: string) {
|
||||
vrmEmote.value?.setEmotionWithResetAfter(expression, 1000)
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { VRMCore } from '@pixiv/three-vrm-core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface EmotionState {
|
||||
expression?: {
|
||||
name: string
|
||||
value: number
|
||||
duration?: number
|
||||
curve?: (t: number) => number
|
||||
}[]
|
||||
blendDuration?: number
|
||||
}
|
||||
|
||||
export function useVRMEmote(vrm: VRMCore) {
|
||||
const currentEmotion = ref<string | null>(null)
|
||||
const isTransitioning = ref(false)
|
||||
const transitionProgress = ref(0)
|
||||
const currentExpressionValues = ref(new Map<string, number>())
|
||||
const targetExpressionValues = ref(new Map<string, number>())
|
||||
const resetTimeout = ref<number>()
|
||||
|
||||
// Utility functions
|
||||
const lerp = (start: number, end: number, t: number): number => {
|
||||
return start + (end - start) * t
|
||||
}
|
||||
|
||||
const easeInOutCubic = (t: number): number => {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2
|
||||
}
|
||||
|
||||
// Emotion states definition
|
||||
const emotionStates = new Map<string, EmotionState>([
|
||||
['happy', {
|
||||
expression: [
|
||||
{ name: 'happy', value: 1.0, duration: 0.3 },
|
||||
{ name: 'aa', value: 0.3 },
|
||||
],
|
||||
blendDuration: 0.3,
|
||||
}],
|
||||
['sad', {
|
||||
expression: [
|
||||
{ name: 'sad', value: 1.0 },
|
||||
{ name: 'oh', value: 0.2 },
|
||||
],
|
||||
blendDuration: 0.3,
|
||||
}],
|
||||
['angry', {
|
||||
expression: [
|
||||
{ name: 'angry', value: 1.0 },
|
||||
{ name: 'ee', value: 0.4 },
|
||||
],
|
||||
blendDuration: 0.2,
|
||||
}],
|
||||
['surprised', {
|
||||
expression: [
|
||||
{ name: 'Surprised', value: 1.0 },
|
||||
{ name: 'oh', value: 0.6 },
|
||||
],
|
||||
blendDuration: 0.1,
|
||||
}],
|
||||
['neutral', {
|
||||
expression: [
|
||||
{ name: 'neutral', value: 1.0 },
|
||||
],
|
||||
blendDuration: 0.5,
|
||||
}],
|
||||
])
|
||||
|
||||
const clearResetTimeout = () => {
|
||||
if (resetTimeout.value) {
|
||||
clearTimeout(resetTimeout.value)
|
||||
resetTimeout.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const setEmotion = (emotionName: string) => {
|
||||
clearResetTimeout()
|
||||
|
||||
if (!emotionStates.has(emotionName)) {
|
||||
console.warn(`Emotion ${emotionName} not found`)
|
||||
return
|
||||
}
|
||||
|
||||
const emotionState = emotionStates.get(emotionName)!
|
||||
currentEmotion.value = emotionName
|
||||
isTransitioning.value = true
|
||||
transitionProgress.value = 0
|
||||
|
||||
// Reset all existing expressions to 0 first
|
||||
if (vrm.expressionManager) {
|
||||
// Get all expression names from the VRM model
|
||||
const expressionNames = Object.keys(vrm.expressionManager.expressionMap)
|
||||
for (const name of expressionNames) {
|
||||
vrm.expressionManager.setValue(name, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Store current expression values as starting point
|
||||
currentExpressionValues.value.clear()
|
||||
targetExpressionValues.value.clear()
|
||||
|
||||
// Store all current expression values
|
||||
for (const expr of emotionState.expression || []) {
|
||||
const currentValue = vrm.expressionManager?.getValue(expr.name) || 0
|
||||
currentExpressionValues.value.set(expr.name, currentValue)
|
||||
targetExpressionValues.value.set(expr.name, expr.value)
|
||||
}
|
||||
}
|
||||
|
||||
const setEmotionWithResetAfter = (emotionName: string, ms: number) => {
|
||||
clearResetTimeout()
|
||||
setEmotion(emotionName)
|
||||
|
||||
// Set timeout to reset to neutral
|
||||
resetTimeout.value = setTimeout(() => {
|
||||
setEmotion('neutral')
|
||||
resetTimeout.value = undefined
|
||||
}, ms) as unknown as number
|
||||
}
|
||||
|
||||
const update = (deltaTime: number) => {
|
||||
if (!isTransitioning.value || !currentEmotion.value)
|
||||
return
|
||||
|
||||
const emotionState = emotionStates.get(currentEmotion.value)!
|
||||
const blendDuration = emotionState.blendDuration || 0.3
|
||||
|
||||
transitionProgress.value += deltaTime / blendDuration
|
||||
if (transitionProgress.value >= 1.0) {
|
||||
transitionProgress.value = 1.0
|
||||
isTransitioning.value = false
|
||||
}
|
||||
|
||||
// Update all expressions
|
||||
for (const [exprName, targetValue] of targetExpressionValues.value) {
|
||||
const startValue = currentExpressionValues.value.get(exprName) || 0
|
||||
const currentValue = lerp(
|
||||
startValue,
|
||||
targetValue,
|
||||
easeInOutCubic(transitionProgress.value),
|
||||
)
|
||||
vrm.expressionManager?.setValue(exprName, currentValue)
|
||||
}
|
||||
}
|
||||
|
||||
const addEmotionState = (emotionName: string, state: EmotionState) => {
|
||||
emotionStates.set(emotionName, state)
|
||||
}
|
||||
|
||||
const removeEmotionState = (emotionName: string) => {
|
||||
emotionStates.delete(emotionName)
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
const dispose = () => {
|
||||
clearResetTimeout()
|
||||
}
|
||||
|
||||
return {
|
||||
currentEmotion,
|
||||
isTransitioning,
|
||||
setEmotion,
|
||||
setEmotionWithResetAfter,
|
||||
update,
|
||||
addEmotionState,
|
||||
removeEmotionState,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
@@ -35,3 +35,13 @@ export const EMOTION_EmotionMotionName_value = {
|
||||
[Emotion.Awkward]: EmotionAwkwardMotionName,
|
||||
[Emotion.Question]: EmotionQuestionMotionName,
|
||||
}
|
||||
|
||||
export const EMOTION_VRMExpressionName_value = {
|
||||
[Emotion.Happy]: 'happy',
|
||||
[Emotion.Sad]: 'sad',
|
||||
[Emotion.Angry]: 'angry',
|
||||
[Emotion.Think]: undefined,
|
||||
[Emotion.Surprise]: 'surprised',
|
||||
[Emotion.Awkward]: undefined,
|
||||
[Emotion.Question]: undefined,
|
||||
} satisfies Record<Emotion, string | undefined>
|
||||
|
||||
Reference in New Issue
Block a user