feat(stage-ui-three-performance-runtime): init new experimental three performance runtime design (#809)

This commit is contained in:
Lilia_Chen
2025-12-17 10:50:06 +08:00
committed by GitHub
parent 588978d9ef
commit c74f335253
10 changed files with 587 additions and 1 deletions
+1
View File
@@ -32,6 +32,7 @@
"@proj-airi/server-sdk": "workspace:^",
"@proj-airi/stage-ui": "workspace:^",
"@proj-airi/stage-ui-three": "workspace:^",
"@proj-airi/stage-ui-three-performance-runtime": "workspace:^",
"@proj-airi/ui": "workspace:^",
"@proj-airi/ui-transitions": "workspace:^",
"@standard-schema/spec": "^1.0.0",
@@ -0,0 +1,311 @@
<script setup lang="ts">
import type { TTSChunkItem } from '@proj-airi/stage-ui/utils/tts'
import type { ChatProvider, SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
import { ThreeScene } from '@proj-airi/stage-ui-three'
import { animations } from '@proj-airi/stage-ui-three/assets/vrm'
import { useDelayMessageQueue, useEmotionsMessageQueue, usePipelineCharacterSpeechPlaybackQueueStore, usePipelineWorkflowTextSegmentationStore } from '@proj-airi/stage-ui/composables/queues'
import { llmInferenceEndToken } from '@proj-airi/stage-ui/constants'
import { EMOTION_EmotionMotionName_value, EMOTION_VRMExpressionName_value, EmotionThinkMotionName } from '@proj-airi/stage-ui/constants/emotions'
import { useAudioContext, useSpeakingStore } from '@proj-airi/stage-ui/stores/audio'
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { createQueue } from '@proj-airi/stage-ui/utils/queue'
import { generateSpeech } from '@xsai/generate-speech'
import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted, ref } from 'vue'
// VRM scene refs
const sceneRef = ref<InstanceType<typeof ThreeScene>>()
// Playback + lip sync (VRM uses currentAudioSource)
const characterSpeechPlaybackQueue = usePipelineCharacterSpeechPlaybackQueueStore()
const { connectAudioContext, connectAudioAnalyser, clearAll, onPlaybackStarted, onPlaybackFinished } = characterSpeechPlaybackQueue
const { currentAudioSource, playbackQueue } = storeToRefs(characterSpeechPlaybackQueue)
// Audio context / analyser
const { audioContext } = useAudioContext()
connectAudioContext(audioContext)
const audioAnalyser = ref<AnalyserNode>()
function setupAnalyser() {
if (!audioAnalyser.value) {
audioAnalyser.value = audioContext.createAnalyser()
connectAudioAnalyser(audioAnalyser.value)
}
}
// Settings + force VRM model
const settingsStore = useSettings()
const { stageModelRenderer, stageModelSelected, stageModelSelectedUrl, stageViewControlsEnabled } = storeToRefs(settingsStore)
onMounted(async () => {
// Preserve existing VRM selection if available; otherwise fall back to preset VRM
const needsFallback = !stageModelSelectedUrl.value || stageModelRenderer.value !== 'vrm'
if (needsFallback)
stageModelSelected.value = 'preset-vrm-1'
await settingsStore.updateStageModel()
setupAnalyser()
})
// Speech
const providersStore = useProvidersStore()
const speechStore = useSpeechStore()
const { activeSpeechProvider, activeSpeechVoice, activeSpeechModel, ssmlEnabled, pitch } = storeToRefs(speechStore)
const consciousnessStore = useConsciousnessStore()
const { activeProvider: activeChatProvider, activeModel: activeChatModel } = storeToRefs(consciousnessStore)
// Text segmentation
const textSegmentationStore = usePipelineWorkflowTextSegmentationStore()
const { onTextSegmented, clearHooks: clearTextSegmentationHooks } = textSegmentationStore
const { textSegmentationQueue } = storeToRefs(textSegmentationStore)
clearTextSegmentationHooks()
// Emotion/delay queues (special tokens)
const delaysQueue = useDelayMessageQueue()
const emotionMessageQueue = useEmotionsMessageQueue(createQueue({ handlers: [] }))
emotionMessageQueue.on('enqueue', (token) => {
log(` - special 入队:${token}`)
})
emotionMessageQueue.on('dequeue', (token) => {
log(`special 出队处理:${token}`)
})
// State
const { mouthOpenSize } = storeToRefs(useSpeakingStore())
const nowSpeaking = ref(false)
const currentMotion = ref<{ group: string }>({ group: EmotionThinkMotionName })
const logLines = ref<string[]>([])
const chatInput = ref('')
const chatStore = useChatStore()
const chatMessages = computed(() => {
return chatStore.messages
.filter(msg => msg.role !== 'system')
.map((msg) => {
const text = typeof msg.content === 'string'
? msg.content
: Array.isArray(msg.content)
? msg.content.map((part: any) => typeof part === 'string' ? part : part.text ?? '').join('')
: JSON.stringify(msg.content ?? '')
return { role: msg.role as 'user' | 'assistant', text }
})
})
function log(line: string) {
logLines.value = [line, ...logLines.value].slice(0, 50)
}
// TTS generation handler
async function handleSpeechGeneration(ctx: { data: TTSChunkItem }) {
try {
if (!activeSpeechProvider.value || !activeSpeechVoice.value) {
console.warn('No active speech provider configured')
return
}
const provider = await providersStore.getProviderInstance(activeSpeechProvider.value) as SpeechProviderWithExtraOptions<string, any>
if (!provider) {
console.error('Failed to initialize speech provider')
return
}
if (ctx.data.chunk === '' && !ctx.data.special)
return
if (ctx.data.chunk === '' && ctx.data.special) {
// log(`特殊标记:${ctx.data.special}`)
emotionMessageQueue.enqueue(ctx.data.special)
return
}
const providerConfig = providersStore.getProviderConfig(activeSpeechProvider.value)
const input = ssmlEnabled.value
? speechStore.generateSSML(ctx.data.chunk, activeSpeechVoice.value, { ...providerConfig, pitch: pitch.value })
: ctx.data.chunk
const res = await generateSpeech({
...provider.speech(activeSpeechModel.value, providerConfig),
input,
voice: activeSpeechVoice.value.id,
})
const audioBuffer = await audioContext.decodeAudioData(res)
log(` - 排队:${ctx.data.chunk}${ctx.data.special ? ` [special: ${ctx.data.special}]` : ''}`)
playbackQueue.value.enqueue({ audioBuffer, text: ctx.data.chunk, special: ctx.data.special })
}
catch (error) {
console.error('Speech generation failed:', error)
}
}
const ttsQueue = createQueue<TTSChunkItem>({
handlers: [
handleSpeechGeneration,
],
})
// text segmentation hooks
onTextSegmented((chunkItem) => {
ttsQueue.enqueue(chunkItem)
})
async function sendChat() {
const content = chatInput.value.trim()
if (!content)
return
const provider = await providersStore.getProviderInstance(activeChatProvider.value)
if (!provider || !activeChatModel.value) {
log('未配置聊天模型或 provider')
return
}
try {
await chatStore.send(content, {
model: activeChatModel.value,
chatProvider: provider as ChatProvider,
})
chatInput.value = ''
}
catch (err) {
console.error(err)
log('发送到 LLM 失败')
}
}
function resetChat() {
chatStore.cleanupMessages()
chatInput.value = ''
logLines.value = []
clearAll()
}
// Chat hooks (reuse Stage pipeline but Live2D removed)
const { onBeforeMessageComposed, onBeforeSend, onTokenLiteral, onTokenSpecial, onStreamEnd } = chatStore
const chatHookCleanups: Array<() => void> = []
chatHookCleanups.push(onBeforeMessageComposed(async () => {
clearAll()
setupAnalyser()
logLines.value = []
}))
chatHookCleanups.push(onBeforeSend(async () => {
currentMotion.value = { group: EmotionThinkMotionName }
}))
chatHookCleanups.push(onTokenLiteral(async (literal) => {
textSegmentationQueue.value.enqueue({ type: 'literal', value: literal })
}))
chatHookCleanups.push(onTokenSpecial(async (special) => {
textSegmentationQueue.value.enqueue({ type: 'special', value: special })
}))
chatHookCleanups.push(onStreamEnd(async () => {
delaysQueue.enqueue(llmInferenceEndToken)
}))
// Wire playback to VRM + logs
onPlaybackFinished(({ special }) => {
nowSpeaking.value = false
mouthOpenSize.value = 0
if (special) {
log(`播放结束,special: ${special}`)
const motion = EMOTION_EmotionMotionName_value[special as keyof typeof EMOTION_EmotionMotionName_value]
const expression = EMOTION_VRMExpressionName_value[special as keyof typeof EMOTION_VRMExpressionName_value]
if (motion)
currentMotion.value = { group: motion }
if (expression)
sceneRef.value?.setExpression(expression)
}
})
onPlaybackStarted(({ text }) => {
nowSpeaking.value = true
log(`播放开始:${text}`)
})
onUnmounted(() => {
chatHookCleanups.forEach(dispose => dispose?.())
clearAll()
})
</script>
<template>
<div p-4 space-y-4>
<div text-lg font-600>
Performance Layer Playground复刻 Stage去掉 Live2D
</div>
<div grid gap-4 lg:grid-cols-2>
<div border="1 solid neutral-300/40 dark:neutral-700/40" h-100 min-h-80 overflow-hidden rounded-2xl>
<ThreeScene
v-if="stageModelRenderer === 'vrm'"
ref="sceneRef"
:model-src="stageModelSelectedUrl"
:idle-animation="animations.idleLoop.toString()"
:current-audio-source="currentAudioSource"
:show-axes="stageViewControlsEnabled"
:paused="false"
@error="console.error"
/>
<div v-else p-4 text-sm text-red-500>
请选择 VRM 模型当前模型类型不支持
</div>
</div>
<div class="border border-neutral-300/50 rounded-xl p-3 text-xs leading-relaxed space-y-3 dark:border-neutral-700/60">
<div font-600>
聊天 / 播放
</div>
<div class="h-60 overflow-auto border border-neutral-200/60 rounded-lg p-2 dark:border-neutral-700/60">
<div v-for="(msg, idx) in chatMessages" :key="idx" class="mb-2">
<div class="text-[11px] text-neutral-500">
{{ msg.role === 'user' ? 'User' : 'AIRI' }}
</div>
<div class="whitespace-pre-wrap break-words text-sm">
{{ msg.text }}
</div>
</div>
<div v-if="!chatMessages.length" class="text-sm text-neutral-500">
输入消息进行对话
</div>
</div>
<div class="flex items-center gap-2">
<input
v-model="chatInput"
class="flex-1 border border-neutral-300/60 rounded-lg bg-white px-3 py-2 text-sm dark:bg-neutral-900/60"
placeholder="输入消息,点击发送"
@keyup.enter="sendChat"
>
<button
class="rounded-lg bg-primary-500 px-3 py-2 text-white disabled:bg-neutral-400"
:disabled="!chatInput.trim()"
@click="sendChat"
>
发送
</button>
<button
class="border border-neutral-300/60 rounded-lg px-3 py-2 text-sm"
@click="resetChat"
>
重置对话
</button>
</div>
<div class="border border-neutral-200/60 rounded-lg p-2 dark:border-neutral-700/60">
<div mb-1 font-600>
播放队列 / 日志
</div>
<ul class="max-h-60 overflow-auto space-y-1">
<li v-for="line in logLines" :key="line">
{{ line }}
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<route lang="yaml">
meta:
layout: settings
</route>
@@ -68,6 +68,12 @@ const menu = computed(() => [
icon: 'i-solar:sledgehammer-bold-duotone',
to: '/devtools/providers-transcription-realtime-aliyun-nls',
},
{
title: 'Performance Playground',
description: 'Test performance runtime: VRM expressions + TTS lip sync',
icon: 'i-solar:sledgehammer-bold-duotone',
to: '/devtools/performance-playground',
},
])
</script>
@@ -0,0 +1,30 @@
{
"name": "@proj-airi/stage-ui-three-performance-runtime",
"type": "module",
"private": true,
"description": "Runtime timeline + blackboard utilities for stage-ui-three performances",
"author": {
"name": "Moeru AI Project AIRI Team",
"email": "airi@moeru.ai",
"url": "https://github.com/moeru-ai"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/moeru-ai/airi.git",
"directory": "packages/stage-ui-three-performance-runtime"
},
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"pinia": "^3.0.4",
"vue": "^3.5.25"
},
"devDependencies": {
"typescript": "^5.6.3"
}
}
@@ -0,0 +1,137 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export interface BlackboardGazeTarget {
type: 'screen' | 'world'
position?: { x: number, y: number, z?: number }
}
export interface BlackboardSpeechState {
speaking: boolean
lastText?: string
lastSpecial?: string
playheadMs?: number
}
export interface BlackboardEmotionState {
tag: string | null
intensity: number
}
export interface BlackboardPoseState {
current: string | null
weight?: number
layer?: string
}
export interface BlackboardActorState {
emotion: BlackboardEmotionState
expression: string | null
gaze: BlackboardGazeTarget | null
pose: BlackboardPoseState
speech: BlackboardSpeechState
flags: Record<string, string | number | boolean>
markers: Record<string, number>
}
function createDefaultActorState(): BlackboardActorState {
return {
emotion: { tag: null, intensity: 0 },
expression: null,
gaze: null,
pose: { current: null, weight: 0 },
speech: { speaking: false },
flags: {},
markers: {},
}
}
export const useBlackboardStore = defineStore('stage-performance-blackboard', () => {
const actors = ref<Record<string, BlackboardActorState>>({
default: createDefaultActorState(),
})
const activeActorId = ref('default')
const activeActor = computed(() => actors.value[activeActorId.value] ?? actors.value.default)
function ensureActor(id: string) {
if (!actors.value[id])
actors.value[id] = createDefaultActorState()
}
function setActiveActor(id: string) {
ensureActor(id)
activeActorId.value = id
}
function updateEmotion(id: string, emotion: Partial<BlackboardEmotionState>) {
ensureActor(id)
actors.value[id].emotion = { ...actors.value[id].emotion, ...emotion }
}
function updateExpression(id: string, expression: string | null) {
ensureActor(id)
actors.value[id].expression = expression
}
function updateGaze(id: string, gaze: BlackboardGazeTarget | null) {
ensureActor(id)
actors.value[id].gaze = gaze
}
function updatePose(id: string, pose: Partial<BlackboardPoseState>) {
ensureActor(id)
actors.value[id].pose = { ...actors.value[id].pose, ...pose }
}
function updateSpeech(id: string, speech: Partial<BlackboardSpeechState>) {
ensureActor(id)
actors.value[id].speech = { ...actors.value[id].speech, ...speech }
}
function setFlag(id: string, key: string, value: string | number | boolean) {
ensureActor(id)
actors.value[id].flags[key] = value
}
function clearFlag(id: string, key: string) {
if (!actors.value[id])
return
delete actors.value[id].flags[key]
}
function setMarker(id: string, key: string, timestampMs: number) {
ensureActor(id)
actors.value[id].markers[key] = timestampMs
}
function clearActor(id: string) {
if (!actors.value[id])
return
actors.value[id] = createDefaultActorState()
}
function resetAll() {
actors.value = { default: createDefaultActorState() }
activeActorId.value = 'default'
}
return {
actors,
activeActorId,
activeActor,
setActiveActor,
updateEmotion,
updateExpression,
updateGaze,
updatePose,
updateSpeech,
setFlag,
clearFlag,
setMarker,
clearActor,
resetAll,
}
})
@@ -0,0 +1,2 @@
export * from './blackboard'
export * from './types'
@@ -0,0 +1,71 @@
// Core event/timeline contracts for the performance runtime; kept UI-agnostic so
// adapters (VRM, audio, devtools) can build on top without pulling stage-ui in.
export type PerformanceEventKind = 'speech' | 'viseme' | 'expression' | 'body' | 'marker'
export interface SpeechEventPayload {
text: string
buffer?: AudioBuffer
durationMs?: number
special?: string | null
}
export interface VisemeEventPayload {
id: string
weight?: number
durationMs?: number
}
export interface ExpressionEventPayload {
name: string
weight?: number
durationMs?: number
}
export interface BodyEventPayload {
clip: string
fadeMs?: number
durationMs?: number
}
export interface MarkerEventPayload {
key: string
}
interface PerformanceEventPayloadMap {
speech: SpeechEventPayload
viseme: VisemeEventPayload
expression: ExpressionEventPayload
body: BodyEventPayload
marker: MarkerEventPayload
}
export type PerformanceEventPayload = PerformanceEventPayloadMap[keyof PerformanceEventPayloadMap]
export interface PerformanceEvent<T extends PerformanceEventKind = PerformanceEventKind> {
id: string
kind: T
t: number // milliseconds since timeline start
payload: PerformanceEventPayloadMap[T]
}
export interface PerformanceTrack {
id: string
type: PerformanceEventKind
events: PerformanceEvent[]
}
export interface PerformanceTimeline {
id: string
lengthMs?: number
tracks: PerformanceTrack[]
}
export interface SchedulerHandlers {
onSpeechStart?: (event: PerformanceEvent<'speech'>) => void
onSpeechEnd?: (event: PerformanceEvent<'speech'>) => void
onViseme?: (event: PerformanceEvent<'viseme'>) => void
onExpression?: (event: PerformanceEvent<'expression'>) => void
onBody?: (event: PerformanceEvent<'body'>) => void
onMarker?: (event: PerformanceEvent<'marker'>) => void
}
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": false,
"rootDir": "src",
"outDir": "dist"
},
"include": [
"src"
]
}
+2 -1
View File
@@ -32,7 +32,8 @@
"./workers/vad": "./src/workers/vad/index.ts",
"./workers/*": "./src/workers/*.ts",
"./workers": "./src/workers/index.ts",
"./utils": "./src/utils/index.ts"
"./utils": "./src/utils/index.ts",
"./utils/tts": "./src/utils/tts.ts"
},
"scripts": {
"typecheck": "vue-tsc --noEmit",
+16
View File
@@ -720,6 +720,9 @@ importers:
'@proj-airi/stage-ui-three':
specifier: workspace:^
version: link:../../packages/stage-ui-three
'@proj-airi/stage-ui-three-performance-runtime':
specifier: workspace:^
version: link:../../packages/stage-ui-three-performance-runtime
'@proj-airi/ui':
specifier: workspace:^
version: link:../../packages/ui
@@ -1811,6 +1814,19 @@ importers:
specifier: ^3.1.8
version: 3.1.8(typescript@5.9.3)
packages/stage-ui-three-performance-runtime:
dependencies:
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@5.9.3)(vue@3.5.25(typescript@5.9.3))
vue:
specifier: ^3.5.25
version: 3.5.25(typescript@5.9.3)
devDependencies:
typescript:
specifier: ^5.6.3
version: 5.9.3
packages/tauri-plugin-mcp:
dependencies:
'@tauri-apps/api':