refactor(lipsync): share renderer vowel policy (#2374)

## Description

The VRM and MMD renderers had the same two boundary problems:

- Both imported the Stage audio store from the `stage-ui` source tree.
- Both kept copies of the wLipSync profile and model-neutral vowel
policy.

This PR solves both problems as one architecture change.

```text
Stage owns AudioContext and the current audio source
  -> ThreeScene / MMDScene receive both as inputs
    -> model-driver-lipsync owns the profile and shared vowel policy
      -> VRM maps AEIOU to aa / ee / ih / oh / ou
      -> MMD maps AEIOU to vowelA / vowelE / vowelI / vowelO / vowelU
```

The shared driver owns viseme projection, winner and runner selection,
silence handling, and smoothing. It has no Vue, Three.js, VRM, or MMD
dependency. Each renderer keeps only its model-specific mapping.

The renderer packages no longer import the Stage audio store or depend
directly on `wlipsync`. Stage passes the owning `AudioContext` down with
the current audio source. Cleanup disconnects only the wLipSync node
owned by that renderer.

## Current solution

The package-boundary review identified two related findings:

- Finding 3: The VRM and MMD renderers imported `stage-ui` audio state
through source paths.
- Finding 4: The same renderers duplicated the wLipSync profile and
model-neutral vowel policy.

Both findings came from one missing boundary between Stage, the lip-sync
driver, and the renderer adapters. This PR fixes that boundary with one
ownership change.

| Layer | Responsibility |
| --- | --- |
| `stage-ui` | Owns the `AudioContext` and the current audio source. It
passes both values to each renderer. |
| `stage-ui-three` and `stage-ui-mmd` | Connect renderer components to
the shared driver. They do not import the Stage audio store. |
| `model-driver-lipsync/runtime/wlipsync` | Exposes `createWLipSyncNode`
as the public runtime entry. |
| `model-driver-lipsync/shared/wlipsync` | Owns the profile, public
types, and model-neutral vowel policy. |

Runtime flow:

1. `Stage.vue` passes `audioContext` and `currentAudioSource` to
`ThreeScene` or `MMDScene`.
2. The renderer composable watches the context and creates one wLipSync
node for that context.
3. The source watcher connects the current audio source to that node.
4. Each render frame sends the wLipSync values to
`createWLipSyncVowelDriver`.
5. The renderer adapter applies the returned AEIOU weights to its model
controls.
6. Cleanup disconnects the exact source and node pair that the renderer
owns.

The shared policy keeps the current renderer behavior:

- It maps the raw `S` viseme to `I`.
- It selects the two strongest vowels.
- It uses the existing volume and silence thresholds.
- It keeps the 160 ms silence window.
- It keeps the existing attack, release, cap, and output scale values.

The renderer-specific mappings stay in their renderer packages:

- VRM maps AEIOU to `aa`, `ee`, `ih`, `oh`, and `ou`.
- MMD maps AEIOU to `vowelA`, `vowelE`, `vowelI`, `vowelO`, and
`vowelU`.

This solution removes both duplicate profile files. It also removes the
direct `wlipsync` dependencies from both renderer packages.

## Linked Issues

None.

## Additional Context

There is no intended visual change. This refactor preserves the existing
vowel weights and renderer mappings.

Verification:

- `sem diff --staged --no-cosmetics -v --file-exts .ts .tsx`
- `sem diff --staged --no-cosmetics -v --file-exts .vue`
- `sem impact --entity-id <useVRMLipSync> --json --dependents`
- `sem impact --entity-id <useMMDLipSync> --json --dependents`
- `pnpm -F @proj-airi/model-driver-lipsync test` — 3 tests passed
- `pnpm -F @proj-airi/stage-ui-three exec vitest run` — 18 tests passed
- `pnpm -F @proj-airi/stage-ui-mmd exec vitest run` — 23 tests passed
- Type checks passed for `model-driver-lipsync`, `stage-ui-three`,
`stage-ui-mmd`, and `stage-ui`
- `pnpm install --frozen-lockfile --ignore-scripts`
- `pnpm lint` — 0 errors and 7 existing warnings outside this change
- `git diff --check`

The full workspace `pnpm typecheck` was also run. In the isolated
worktree it stopped in unchanged `electron-vueuse` and
`plugin-sdk-tamagotchi` because their generated dependency outputs were
not present. All affected package type checks above passed.
This commit is contained in:
leafyy
2026-08-31 14:58:43 +08:00
committed by GitHub
parent 0a30c2298f
commit aa69ee62b5
21 changed files with 428 additions and 245 deletions
+49
View File
@@ -0,0 +1,49 @@
# `@proj-airi/model-driver-lipsync`
Shared lip-sync profiles and model-neutral mouth-driving policies for AIRI.
## What It Does
- Exposes the shared wLipSync profile.
- Converts raw AEIOUS frames into stable AEIOU weights.
- Owns winner selection, silence detection, and weight smoothing.
- Provides the existing Live2D lip-sync driver.
- Exposes the browser-only wLipSync node factory through a separate runtime entry.
The package does not write weights to VRM expressions or MMD morphs. Each renderer owns that mapping.
## Exports
- `@proj-airi/model-driver-lipsync`: the Live2D driver.
- `@proj-airi/model-driver-lipsync/shared/wlipsync`: the profile, types, and pure vowel driver.
- `@proj-airi/model-driver-lipsync/runtime/wlipsync`: the browser-only audio-node factory.
The shared entry has no Web Audio side effects. Node-based tools and tests can import it safely.
## How To Use It
```ts
import { createWLipSyncNode } from '@proj-airi/model-driver-lipsync/runtime/wlipsync'
import {
createWLipSyncVowelDriver,
wlipsyncProfile,
} from '@proj-airi/model-driver-lipsync/shared/wlipsync'
const node = await createWLipSyncNode(audioContext, wlipsyncProfile)
const driver = createWLipSyncVowelDriver()
const weights = driver.update(node, deltaSeconds)
```
The caller owns the `AudioContext`, the source node, and the source lifecycle.
## When To Use It
- Use the shared entry when a renderer needs standard AEIOU weights.
- Use the runtime entry when browser code creates a wLipSync audio node.
- Keep renderer-specific expression and morph mappings in the renderer package.
## When Not To Use It
- Do not import the runtime entry from Node-only code.
- Do not add Vue, Three.js, VRM, or MMD dependencies to this package.
- Do not move renderer-specific model writes into the shared driver.
@@ -6,10 +6,18 @@
"exports": {
".": "./src/index.ts",
"./live2d": "./src/live2d/index.ts",
"./runtime/wlipsync": "./src/runtime/wlipsync/index.ts",
"./shared/wlipsync": "./src/shared/wlipsync/index.ts",
"./shared/wlipsync/profile.json": "./src/shared/wlipsync/profile.json"
},
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"wlipsync": "catalog:"
},
"devDependencies": {
"vitest": "catalog:vitest"
}
}
@@ -0,0 +1 @@
export { createWLipSyncNode } from 'wlipsync'
@@ -1,2 +1,3 @@
export { default as wlipsyncProfile } from './profile.json' with { type: 'json' }
export type { Profile } from 'wlipsync'
export * from './vowel-driver'
export type { Profile, WLipSyncAudioNode } from 'wlipsync'
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { createWLipSyncVowelDriver } from './index'
describe('wLipSync vowel driver', () => {
it('maps S to I and returns only the two strongest vowels', () => {
const driver = createWLipSyncVowelDriver(() => 1000)
const weights = driver.update({
volume: 1,
weights: {
A: 1,
E: 0.8,
I: 0.1,
O: 0.6,
S: 0.9,
U: 0.4,
},
}, 1)
expect(weights.A).toBeCloseTo(0.49)
expect(weights.I).toBeCloseTo(0.245)
expect(weights.E).toBe(0)
expect(weights.O).toBe(0)
expect(weights.U).toBe(0)
})
it('uses the release rate when the audio becomes silent', () => {
let now = 1000
const driver = createWLipSyncVowelDriver(() => now)
const activeWeights = driver.update({ volume: 1, weights: { A: 1 } }, 0.016)
now += 16
const silentWeights = driver.update({ volume: 0, weights: {} }, 0.016)
expect(silentWeights.A).toBeGreaterThan(0)
expect(silentWeights.A).toBeLessThan(activeWeights.A)
})
it('resets all smoothing state', () => {
const driver = createWLipSyncVowelDriver(() => 1000)
driver.update({ volume: 1, weights: { A: 1 } }, 1)
driver.reset()
const weights = driver.update({ volume: 0, weights: {} }, 1)
expect(weights).toEqual({ A: 0, E: 0, I: 0, O: 0, U: 0 })
})
})
@@ -0,0 +1,130 @@
const RAW_VISEMES = ['A', 'E', 'I', 'O', 'U', 'S'] as const
/** The vowels that model renderers can apply to their mouth controls. */
export const WLIP_SYNC_VOWELS = ['A', 'E', 'I', 'O', 'U'] as const
/** The wLipSync values that the shared vowel driver reads for one frame. */
export interface WLipSyncFrame {
/** The normalized volume from the wLipSync audio node. */
volume?: number
/** The raw AEIOUS viseme weights from the wLipSync audio node. */
weights: Readonly<Partial<Record<WLipSyncRawViseme, number>>>
}
/** A vowel weight that a model renderer can apply to its mouth controls. */
export type WLipSyncVowel = typeof WLIP_SYNC_VOWELS[number]
/**
* Converts wLipSync frames into stable vowel weights for model renderers.
*
* The driver owns the winner selection, silence policy, and smoothing state.
* It does not know how a renderer stores or applies mouth controls.
*/
export interface WLipSyncVowelDriver {
/** Clears the smoothing and silence state. */
reset: () => void
/**
* Reads one wLipSync frame and returns weights for the five vowels.
*
* @param frame - The current values from the wLipSync audio node.
* @param deltaSeconds - The elapsed render time. The default is 0.016 seconds.
*/
update: (frame: WLipSyncFrame, deltaSeconds?: number) => WLipSyncVowelWeights
}
/** The normalized vowel weights that a renderer maps to model controls. */
export type WLipSyncVowelWeights = Record<WLipSyncVowel, number>
type WLipSyncRawViseme = typeof RAW_VISEMES[number]
const RAW_TO_VOWEL: Record<WLipSyncRawViseme, WLipSyncVowel> = {
A: 'A',
E: 'E',
I: 'I',
O: 'O',
S: 'I',
U: 'U',
}
/**
* Creates the shared vowel driver used by the VRM and MMD renderers.
*
* @param now - Returns the current monotonic time in milliseconds.
*
* @example
* const driver = createWLipSyncVowelDriver()
* driver.update({ volume: 1, weights: { A: 0.8, I: 0.2 } }, 1)
* // => { A: 0.49, E: 0, I: 0.078..., O: 0, U: 0 }
*/
export function createWLipSyncVowelDriver(
now: () => number = () => performance.now(),
): WLipSyncVowelDriver {
const smoothWeights = emptyVowelWeights()
let lastActiveAt = 0
function reset() {
for (const vowel of WLIP_SYNC_VOWELS)
smoothWeights[vowel] = 0
lastActiveAt = 0
}
function update(frame: WLipSyncFrame, deltaSeconds = 0.016): WLipSyncVowelWeights {
const amplitude = Math.min((frame.volume ?? 0) * 0.9, 1) ** 0.7
const projected = emptyVowelWeights()
for (const rawViseme of RAW_VISEMES) {
const vowel = RAW_TO_VOWEL[rawViseme]
const rawWeight = frame.weights[rawViseme] ?? 0
projected[vowel] = Math.max(projected[vowel], rawWeight * amplitude)
}
let winner: WLipSyncVowel = 'I'
let runner: WLipSyncVowel = 'E'
let winnerWeight = -Infinity
let runnerWeight = -Infinity
for (const vowel of WLIP_SYNC_VOWELS) {
const weight = projected[vowel]
if (weight > winnerWeight) {
runnerWeight = winnerWeight
runner = winner
winnerWeight = weight
winner = vowel
}
else if (weight > runnerWeight) {
runnerWeight = weight
runner = vowel
}
}
const currentTime = now()
let silent = amplitude < 0.04 || winnerWeight < 0.05
if (!silent)
lastActiveAt = currentTime
if (currentTime - lastActiveAt > 160)
silent = true
const targetWeights = emptyVowelWeights()
if (!silent) {
targetWeights[winner] = Math.min(0.7, winnerWeight)
targetWeights[runner] = Math.min(0.35, runnerWeight * 0.6)
}
const result = emptyVowelWeights()
for (const vowel of WLIP_SYNC_VOWELS) {
const from = smoothWeights[vowel]
const to = targetWeights[vowel]
const rate = 1 - Math.exp(-(to > from ? 50 : 30) * deltaSeconds)
smoothWeights[vowel] = from + (to - from) * rate
result[vowel] = (smoothWeights[vowel] <= 0.01 ? 0 : smoothWeights[vowel]) * 0.7
}
return result
}
return { reset, update }
}
function emptyVowelWeights(): WLipSyncVowelWeights {
return { A: 0, E: 0, I: 0, O: 0, U: 0 }
}
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
})
+1
View File
@@ -45,6 +45,7 @@ import { MMDScene, useMMD } from '@proj-airi/stage-ui-mmd'
```vue
<MMDScene
v-model:state="state"
:audio-context="audioContext"
:model-src="modelUrl"
:cursor-position="cursorPosition"
:current-audio-source="audioSource"
+2 -2
View File
@@ -36,6 +36,7 @@
"@moeru/std": "catalog:",
"@moeru/three-mmd": "catalog:",
"@moeru/three-mmd-physics-ammo": "catalog:",
"@proj-airi/model-driver-lipsync": "workspace:^",
"@proj-airi/stage-shared": "workspace:^",
"@proj-airi/ui": "workspace:^",
"@vueuse/core": "catalog:",
@@ -47,8 +48,7 @@
"pinia": "catalog:",
"three": "catalog:",
"three-stdlib": "catalog:",
"vue": "catalog:",
"wlipsync": "catalog:"
"vue": "catalog:"
},
"devDependencies": {
"@types/culori": "catalog:",
File diff suppressed because one or more lines are too long
@@ -33,7 +33,7 @@ import {
WebGLRenderer,
} from 'three'
import { OrbitControls } from 'three-stdlib'
import { onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
import { onMounted, onUnmounted, ref, toRef, watch } from 'vue'
import {
createGazeController,
@@ -57,6 +57,8 @@ import {
} from '../../utils/mmd-materials'
const props = withDefaults(defineProps<{
/** The context that owns `currentAudioSource`. */
audioContext?: AudioContext
modelSrc?: string
modelId?: string
paused?: boolean
@@ -120,11 +122,11 @@ const registeredMotions = new Set<string>()
const clock = new Clock()
let rafHandle = 0
// Lip-sync owns Vue lifecycle hooks, so it must be created during setup. It
// is fed the live audio source and applied to whichever morphs are mounted.
const audioRef = shallowRef<AudioBufferSourceNode | undefined>(props.currentAudioSource)
watch(() => props.currentAudioSource, v => audioRef.value = v)
const lipSync = useMMDLipSync(audioRef)
// Lip-sync owns Vue lifecycle hooks, so it must be created during setup.
const lipSync = useMMDLipSync(
toRef(props, 'audioContext'),
toRef(props, 'currentAudioSource'),
)
const blink = useMMDBlink()
let gaze: ReturnType<typeof createGazeController> | undefined
@@ -1,147 +1,97 @@
import type { Profile, WLipSyncAudioNode, WLipSyncVowel } from '@proj-airi/model-driver-lipsync/shared/wlipsync'
import type { Ref } from 'vue'
import type { Profile } from 'wlipsync'
import type { VowelSlot } from '../../constants/morphs'
import type { MorphController } from './morph'
import { useAsyncState } from '@vueuse/core'
import { onUnmounted, watch } from 'vue'
import { createWLipSyncNode } from 'wlipsync'
import { createWLipSyncNode } from '@proj-airi/model-driver-lipsync/runtime/wlipsync'
import {
createWLipSyncVowelDriver,
WLIP_SYNC_VOWELS,
wlipsyncProfile,
} from '@proj-airi/model-driver-lipsync/shared/wlipsync'
import { shallowRef, watch } from 'vue'
import profile from '../../assets/lip-sync-profile.json' with { type: 'json' }
// NOTICE:
// Cross-package source import (no package.json dependency) to reach the shared
// AudioContext. Root cause: the audio context is owned by `@proj-airi/stage-ui`,
// which in turn depends on this package, so adding it as a dependency would
// create a cycle. The VRM renderer reaches the same store the same way.
// Source: packages/stage-ui-three/src/composables/vrm/lip-sync.ts.
// Removal condition: the AudioContext provider moves to a dependency-free
// shared package both renderers can import.
import { useAudioContext } from '../../../../stage-ui/src/stores/audio'
/** wLipSync emits 6 visemes; we fold the sibilant `S` into `I`. */
const RAW_KEYS = ['A', 'E', 'I', 'O', 'U', 'S'] as const
type LipKey = 'A' | 'E' | 'I' | 'O' | 'U'
const LIP_KEYS: LipKey[] = ['A', 'E', 'I', 'O', 'U']
/** Maps wLipSync visemes onto the MMD vowel morph slots (あいうえお). */
const LIP_TO_SLOT: Record<LipKey, VowelSlot> = {
const MMD_SLOT_BY_VOWEL: Record<WLipSyncVowel, VowelSlot> = {
A: 'vowelA',
E: 'vowelE',
I: 'vowelI',
O: 'vowelO',
U: 'vowelU',
}
const RAW_TO_LIP: Record<typeof RAW_KEYS[number], LipKey> = {
A: 'A',
E: 'E',
I: 'I',
O: 'O',
U: 'U',
S: 'I',
}
const ATTACK = 50 // approach speed toward the next mouth shape
const RELEASE = 30 // decay speed when a shape ends
const CAP = 0.7 // max morph weight, mirrors the VRM tuning
const SILENCE_VOL = 0.04
const SILENCE_GAIN = 0.05
const IDLE_MS = 160
/**
* Audio-driven mouth animation for MMD models.
* Applies shared wLipSync vowel weights to MMD mouth morphs.
*
* Reuses the exact wLipSync profile and winner/runner blending strategy as
* the VRM renderer (only the two strongest visemes are mixed, so the wide
* "A" shape does not dominate), but writes the result to MMD vowel morphs
* through a {@link MorphController} instead of VRM expressions.
*
* Returns an `update(delta)` to call once per frame, after the animation
* helper has run, so lip-sync wins over any VMD mouth keyframes.
* The caller owns the AudioContext and audio source lifecycle. This composable
* owns only the connection between that source and its wLipSync node.
* Call `update` after the MMD animation helper so lip-sync wins over VMD morphs.
*/
export function useMMDLipSync(audioNode: Ref<AudioBufferSourceNode | undefined>) {
const { audioContext } = useAudioContext()
const { state: lipSyncNode, isReady } = useAsyncState(createWLipSyncNode(audioContext, profile as Profile), undefined)
export function useMMDLipSync(
audioContext: Readonly<Ref<AudioContext | undefined>>,
audioSource: Readonly<Ref<AudioBufferSourceNode | undefined>>,
) {
const lipSyncNode = shallowRef<WLipSyncAudioNode>()
const vowelDriver = createWLipSyncVowelDriver()
const smoothState: Record<LipKey, number> = { A: 0, E: 0, I: 0, O: 0, U: 0 }
let lastActiveAt = 0
watch([isReady, audioNode], ([ready, newAudioNode], [, oldAudioNode]) => {
if (oldAudioNode && oldAudioNode !== newAudioNode) {
try {
oldAudioNode.disconnect()
}
catch {}
}
if (!ready || !newAudioNode || !lipSyncNode.value)
watch(audioContext, (context, _, onCleanup) => {
lipSyncNode.value = undefined
vowelDriver.reset()
if (!context)
return
try {
newAudioNode.connect(lipSyncNode.value)
}
catch {}
let active = true
let createdNode: undefined | WLipSyncAudioNode
onCleanup(() => {
active = false
createdNode?.disconnect()
})
void createWLipSyncNode(context, wlipsyncProfile as Profile)
.then((node) => {
createdNode = node
if (!active) {
node.disconnect()
return
}
lipSyncNode.value = node
})
.catch((error) => {
if (active)
console.error('[stage-ui-mmd] Failed to create the MMD lip-sync node.', error)
})
}, { immediate: true })
onUnmounted(() => audioNode.value?.disconnect())
watch([lipSyncNode, audioSource], ([node, source], _, onCleanup) => {
if (!node || !source)
return
try {
source.connect(node)
}
catch (error) {
console.error('[stage-ui-mmd] Failed to connect the MMD lip-sync node.', error)
return
}
onCleanup(() => {
try {
source.disconnect(node)
}
catch {
// The source can end before Vue runs this watcher cleanup.
}
})
}, { immediate: true })
function update(morphs: MorphController | undefined, delta = 0.016) {
const node = lipSyncNode.value
if (!morphs || !node)
return
const vol = node.volume ?? 0
const amp = Math.min(vol * 0.9, 1) ** 0.7
// Project the 6 raw visemes down to 5 vowels, scaled by amplitude.
const projected: Record<LipKey, number> = { A: 0, E: 0, I: 0, O: 0, U: 0 }
for (const raw of RAW_KEYS) {
const lip = RAW_TO_LIP[raw]
const rawVal = node.weights[raw] ?? 0
projected[lip] = Math.max(projected[lip], rawVal * amp)
}
// Only blend the two strongest vowels. Mixing all five biases toward the
// wide "A" shape because it has the largest deformation.
let winner: LipKey = 'I'
let runner: LipKey = 'E'
let winnerVal = -Infinity
let runnerVal = -Infinity
for (const key of LIP_KEYS) {
const val = projected[key]
if (val > winnerVal) {
runnerVal = winnerVal
runner = winner
winnerVal = val
winner = key
}
else if (val > runnerVal) {
runnerVal = val
runner = key
}
}
// Treat low energy / brief gaps as silence so the mouth fully closes.
const now = performance.now()
let silent = amp < SILENCE_VOL || winnerVal < SILENCE_GAIN
if (!silent)
lastActiveAt = now
if (now - lastActiveAt > IDLE_MS)
silent = true
const target: Record<LipKey, number> = { A: 0, E: 0, I: 0, O: 0, U: 0 }
if (!silent) {
target[winner] = Math.min(CAP, winnerVal)
target[runner] = Math.min(CAP * 0.5, runnerVal * 0.6)
}
for (const key of LIP_KEYS) {
const from = smoothState[key]
const to = target[key]
const rate = 1 - Math.exp(-(to > from ? ATTACK : RELEASE) * delta)
smoothState[key] = from + (to - from) * rate
const weight = (smoothState[key] <= 0.01 ? 0 : smoothState[key]) * 0.7
morphs.set(LIP_TO_SLOT[key], weight)
}
const weights = vowelDriver.update(node, delta)
for (const vowel of WLIP_SYNC_VOWELS)
morphs.set(MMD_SLOT_BY_VOWEL[vowel], weights[vowel])
}
return { update }
+6
View File
@@ -32,6 +32,12 @@ VRM runtime management is explicit in this package.
This keeps ordinary remounts and HMR from immediately forcing deep VRM disposal, while still making model switches deterministic.
## Lip-Sync Boundary
`ThreeScene` receives the `AudioContext` and current audio source from its caller. The package does not import the Stage audio store.
The shared driver produces AEIOU weights. The VRM adapter maps these weights to `aa`, `ee`, `ih`, `oh`, and `ou` expressions.
## Scene Lifecycle
`ThreeScene` coordinates two independent async readiness signals before the scene becomes interactive:
+2 -2
View File
@@ -29,6 +29,7 @@
"@pixiv/three-vrm": "catalog:",
"@pixiv/three-vrm-animation": "catalog:",
"@pixiv/three-vrm-core": "catalog:",
"@proj-airi/model-driver-lipsync": "workspace:*",
"@proj-airi/stage-shared": "workspace:*",
"@proj-airi/ui": "workspace:*",
"@tresjs/cientos": "catalog:",
@@ -40,8 +41,7 @@
"pinia": "catalog:",
"postprocessing": "catalog:",
"three": "catalog:",
"vue": "catalog:",
"wlipsync": "catalog:"
"vue": "catalog:"
},
"devDependencies": {
"@types/culori": "catalog:",
File diff suppressed because one or more lines are too long
@@ -108,6 +108,8 @@ import {
* - modelRotationY: The rotation of the model (y-axis)
*/
const props = withDefaults(defineProps<{
/** The context that owns `currentAudioSource`. */
audioContext?: AudioContext
currentAudioSource?: AudioBufferSourceNode
cursorPosition?: { x: number, y: number }
lastCommittedModelSrc?: string
@@ -148,6 +150,7 @@ const emit = defineEmits<{
}>()
const {
audioContext,
currentAudioSource,
lastCommittedModelSrc,
modelSrc,
@@ -195,7 +198,7 @@ type UpdatableMaterial = Material & {
const blink = useBlink()
const idleEyeSaccades = useIdleEyeSaccades()
const vrmEmote = ref<ReturnType<typeof useVRMEmote>>()
const vrmLipSync = useVRMLipSync(currentAudioSource)
const vrmLipSync = useVRMLipSync(audioContext, currentAudioSource)
// For sky box update
const nprProgramVersion = ref(0)
@@ -52,6 +52,8 @@ import { SkyBox } from './Environment'
import { VRMModel } from './Model'
const props = withDefaults(defineProps<{
/** The context that owns `currentAudioSource`. */
audioContext?: AudioContext
currentAudioSource?: AudioBufferSourceNode
cursorPosition?: { x: number, y: number }
modelSrc?: string
@@ -843,6 +845,7 @@ defineExpose({
</Suspense>
<VRMModel
ref="modelRef"
:audio-context="props.audioContext"
:current-audio-source="props.currentAudioSource"
:cursor-position="props.cursorPosition"
:last-committed-model-src="lastCommittedModelSrc"
@@ -1,126 +1,94 @@
import type { VRMCore } from '@pixiv/three-vrm-core'
import type { Profile, WLipSyncAudioNode, WLipSyncVowel } from '@proj-airi/model-driver-lipsync/shared/wlipsync'
import type { Ref } from 'vue'
import type { Profile } from 'wlipsync'
import { useAsyncState } from '@vueuse/core'
import { onUnmounted, watch } from 'vue'
import { createWLipSyncNode } from 'wlipsync'
import { createWLipSyncNode } from '@proj-airi/model-driver-lipsync/runtime/wlipsync'
import {
createWLipSyncVowelDriver,
WLIP_SYNC_VOWELS,
wlipsyncProfile,
} from '@proj-airi/model-driver-lipsync/shared/wlipsync'
import { shallowRef, watch } from 'vue'
import profile from '../../assets/lip-sync-profile.json' with { type: 'json' }
const VRM_EXPRESSION_BY_VOWEL: Record<WLipSyncVowel, string> = {
A: 'aa',
E: 'ee',
I: 'ih',
O: 'oh',
U: 'ou',
}
import { useAudioContext } from '../../../../stage-ui/src/stores/audio'
/**
* Applies shared wLipSync vowel weights to a VRM expression manager.
*
* The caller owns the AudioContext and audio source lifecycle. This composable
* owns only the connection between that source and its wLipSync node.
*/
export function useVRMLipSync(
audioContext: Readonly<Ref<AudioContext | undefined>>,
audioSource: Readonly<Ref<AudioBufferSourceNode | undefined>>,
) {
const lipSyncNode = shallowRef<WLipSyncAudioNode>()
const vowelDriver = createWLipSyncVowelDriver()
export function useVRMLipSync(audioNode: Ref<AudioBufferSourceNode | undefined, AudioBufferSourceNode | undefined>) {
const { audioContext } = useAudioContext()
const { state: lipSyncNode, isReady } = useAsyncState(createWLipSyncNode(audioContext, profile as Profile), undefined)
// https://github.com/mrxz/wLipSync/blob/c3bc4b321dc7e1ca333d75f7aa1e9e746cbbb23a/example/index.js#L50-L66
const RAW_KEYS = ['A', 'E', 'I', 'O', 'U', 'S'] as const
type LipKey = 'A' | 'E' | 'I' | 'O' | 'U'
const LIP_KEYS: LipKey[] = ['A', 'E', 'I', 'O', 'U']
const BLENDSHAPE_MAP: Record<LipKey, string> = {
A: 'aa',
E: 'ee',
I: 'ih',
O: 'oh',
U: 'ou',
}
const RAW_TO_LIP: Record<typeof RAW_KEYS[number], LipKey> = {
A: 'A',
E: 'E',
I: 'I',
O: 'O',
U: 'U',
S: 'I',
}
const smoothState: Record<LipKey, number> = { A: 0, E: 0, I: 0, O: 0, U: 0 }
const ATTACK = 50 // the speed moving to the next mouth shape animation
const RELEASE = 30 // the speed ending the current mouth shape animation
const CAP = 0.7
const SILENCE_VOL = 0.04
const SILENCE_GAIN = 0.05
const IDLE_MS = 160
let lastActiveAt = 0
watch([isReady, audioNode], ([ready, newAudioNode], [, oldAudioNode]) => {
if (oldAudioNode && oldAudioNode !== newAudioNode) {
try {
oldAudioNode.disconnect()
}
catch {}
}
if (!ready || !newAudioNode || !lipSyncNode.value)
watch(audioContext, (context, _, onCleanup) => {
lipSyncNode.value = undefined
vowelDriver.reset()
if (!context)
return
let active = true
let createdNode: undefined | WLipSyncAudioNode
onCleanup(() => {
active = false
createdNode?.disconnect()
})
void createWLipSyncNode(context, wlipsyncProfile as Profile)
.then((node) => {
createdNode = node
if (!active) {
node.disconnect()
return
}
lipSyncNode.value = node
})
.catch((error) => {
if (active)
console.error('[stage-ui-three] Failed to create the VRM lip-sync node.', error)
})
}, { immediate: true })
watch([lipSyncNode, audioSource], ([node, source], _, onCleanup) => {
if (!node || !source)
return
try {
newAudioNode.connect(lipSyncNode.value)
source.connect(node)
}
catch {}
catch (error) {
console.error('[stage-ui-three] Failed to connect the VRM lip-sync node.', error)
return
}
onCleanup(() => {
try {
source.disconnect(node)
}
catch {
// The source can end before Vue runs this watcher cleanup.
}
})
}, { immediate: true })
onUnmounted(() => audioNode.value?.disconnect())
function update(vrm?: VRMCore, delta = 0.016) {
const node = lipSyncNode.value
if (!vrm?.expressionManager || !node)
return
const vol = node.volume ?? 0
const amp = Math.min(vol * 0.9, 1) ** 0.7
// Remapping wLipSync output AEIOUS to AEIOU
const projected: Record<LipKey, number> = { A: 0, E: 0, I: 0, O: 0, U: 0 }
for (const raw of RAW_KEYS) {
const lip = RAW_TO_LIP[raw]
const rawVal = node.weights[raw] ?? 0
projected[lip] = Math.max(projected[lip], rawVal * amp)
}
// winner + runner
// Original code: all AEIOU mouth shape blended together. Because the A mouth shape has the largest deformation, mixing A-E-I-O-U based on their raw weights causes the combined result to be biased heavily toward A in most cases.
// Improved code: Only the 2 mouth shapes with the largest weights will be blended.
let winner: LipKey = 'I'
let runner: LipKey = 'E'
let winnerVal = -Infinity
let runnerVal = -Infinity
for (const key of LIP_KEYS) {
const val = projected[key]
if (val > winnerVal) {
runnerVal = winnerVal
runner = winner
winnerVal = val
winner = key
}
else if (val > runnerVal) {
runnerVal = val
runner = key
}
}
// Detect pause or keep silence
const now = performance.now()
let silent = amp < SILENCE_VOL || winnerVal < SILENCE_GAIN
if (!silent)
lastActiveAt = now
if (now - lastActiveAt > IDLE_MS)
silent = true
// winner + runner weights
const target: Record<LipKey, number> = { A: 0, E: 0, I: 0, O: 0, U: 0 }
if (!silent) {
target[winner] = Math.min(CAP, winnerVal)
target[runner] = Math.min(CAP * 0.5, runnerVal * 0.6)
}
// smoothness and expression generation
for (const key of LIP_KEYS) {
const from = smoothState[key]
const to = target[key]
// lerp
const rate = 1 - Math.exp(-(to > from ? ATTACK : RELEASE) * delta)
smoothState[key] = from + (to - from) * rate
const weight = (smoothState[key] <= 0.01 ? 0 : smoothState[key]) * 0.7
vrm.expressionManager.setValue(BLENDSHAPE_MAP[key], weight)
}
const weights = vowelDriver.update(node, delta)
for (const vowel of WLIP_SYNC_VOWELS)
vrm.expressionManager.setValue(VRM_EXPRESSION_BY_VOWEL[vowel], weights[vowel])
}
return { update }
@@ -1114,6 +1114,7 @@ defineExpose({
:paused="paused"
:show-axes="stageViewControlsEnabled"
:enable-orbit-controls="props.enableOrbitControls"
:audio-context="audioContext"
:current-audio-source="currentAudioSource"
@error="console.error"
@vrm-interact="onVRMInteract"
@@ -1157,6 +1158,7 @@ defineExpose({
:paused="paused"
:cursor-position="cursorPosition"
:enable-orbit-controls="props.enableOrbitControls"
:audio-context="audioContext"
:current-audio-source="currentAudioSource"
@error="console.error"
/>
+10 -6
View File
@@ -3784,6 +3784,10 @@ importers:
wlipsync:
specifier: 'catalog:'
version: 1.3.1
devDependencies:
vitest:
specifier: catalog:vitest
version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1(@noble/hashes@2.3.0)(canvas@3.2.3))(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0(supports-color@10.2.2))(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))
packages/model-driver-magic-live2d:
dependencies:
@@ -4884,6 +4888,9 @@ importers:
'@moeru/three-mmd-physics-ammo':
specifier: 'catalog:'
version: 0.1.0-beta.7(@moeru/three-mmd@0.1.0-beta.7(@types/three@0.185.4)(three@0.185.1))(@types/three@0.185.4)(three@0.185.1)
'@proj-airi/model-driver-lipsync':
specifier: workspace:^
version: link:../model-driver-lipsync
'@proj-airi/stage-shared':
specifier: workspace:^
version: link:../stage-shared
@@ -4920,9 +4927,6 @@ importers:
vue:
specifier: 'catalog:'
version: 3.5.41(typescript@6.0.3)
wlipsync:
specifier: 'catalog:'
version: 1.3.1
devDependencies:
'@types/culori':
specifier: 'catalog:'
@@ -5049,6 +5053,9 @@ importers:
'@pixiv/three-vrm-core':
specifier: 'catalog:'
version: 3.5.5(@types/three@0.185.4)(three@0.185.1)
'@proj-airi/model-driver-lipsync':
specifier: workspace:*
version: link:../model-driver-lipsync
'@proj-airi/stage-shared':
specifier: workspace:*
version: link:../stage-shared
@@ -5085,9 +5092,6 @@ importers:
vue:
specifier: 'catalog:'
version: 3.5.41(typescript@6.0.3)
wlipsync:
specifier: 'catalog:'
version: 1.3.1
devDependencies:
'@types/culori':
specifier: 'catalog:'
+1
View File
@@ -14,6 +14,7 @@ export default defineConfig({
'packages/input-gamepad',
'packages/input-gamepad-vueuse',
'packages/input-playstation-dualsense-5',
'packages/model-driver-lipsync',
'packages/better-ws',
'packages/plugin-sdk',
'packages/plugin-sdk-tamagotchi',