chore(stage-ui,model-driver-lipsync): should throuttle and lerp mouth changing

This commit is contained in:
Neko Ayaka
2025-12-27 05:28:56 +08:00
parent 689e202194
commit 2735296736
2 changed files with 56 additions and 3 deletions
@@ -50,6 +50,16 @@ export interface Live2DLipSyncOptions {
* Defaults to 0.7.
*/
volumeExponent?: number
/**
* Minimum interval (ms) between mouth-open recalculations.
* Defaults to 40ms (~25fps) to avoid overly chattery updates.
*/
mouthUpdateIntervalMs?: number
/**
* Lerp window (ms) for smoothing mouth-open changes.
* Defaults to 120ms; set to 0 to disable smoothing.
*/
mouthLerpWindowMs?: number
}
/**
@@ -68,6 +78,15 @@ export async function createLive2DLipSync(
const cap = options.cap ?? 0.7
const volumeScale = options.volumeScale ?? 0.9
const volumeExponent = options.volumeExponent ?? 0.7
const mouthUpdateIntervalMs = options.mouthUpdateIntervalMs ?? 40
const mouthLerpWindowMs = options.mouthLerpWindowMs ?? 120
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now())
let lastRawMouthOpen = 0
let lastRawUpdateMs = 0
let smoothedMouthOpen = 0
let lastSmoothedMs = 0
const getVowelWeights = (): Record<VowelKey, number> => {
const projected: Record<VowelKey, number> = { A: 0, E: 0, I: 0, O: 0, U: 0 }
@@ -82,11 +101,44 @@ export async function createLive2DLipSync(
return projected
}
const getMouthOpen = () => {
const computeMouthOpen = () => {
const weights = Object.values(getVowelWeights())
return weights.length ? Math.max(...weights) : 0
}
const maybeUpdateRawMouthOpen = (timestamp: number) => {
if (lastRawUpdateMs === 0 || mouthUpdateIntervalMs <= 0 || timestamp - lastRawUpdateMs >= mouthUpdateIntervalMs) {
lastRawMouthOpen = computeMouthOpen()
lastRawUpdateMs = timestamp
}
}
const getSmoothedMouthOpen = (timestamp: number) => {
if (lastSmoothedMs === 0 || mouthLerpWindowMs <= 0) {
smoothedMouthOpen = lastRawMouthOpen
lastSmoothedMs = timestamp
return smoothedMouthOpen
}
const alpha = Math.min(1, (timestamp - lastSmoothedMs) / mouthLerpWindowMs)
smoothedMouthOpen += (lastRawMouthOpen - smoothedMouthOpen) * alpha
lastSmoothedMs = timestamp
return smoothedMouthOpen
}
// Prime mouth state so first frame isn't stale
const initialTimestamp = now()
lastRawMouthOpen = computeMouthOpen()
lastRawUpdateMs = initialTimestamp
smoothedMouthOpen = lastRawMouthOpen
lastSmoothedMs = initialTimestamp
const getMouthOpen = () => {
const timestamp = now()
maybeUpdateRawMouthOpen(timestamp)
return getSmoothedMouthOpen(timestamp)
}
const connectSource = (source: AudioNode) => {
try {
source.connect(node)
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { DuckDBWasmDrizzleDatabase } from '@proj-airi/drizzle-duckdb-wasm'
import type { Live2DLipSync } from '@proj-airi/model-driver-lipsync'
import type { Live2DLipSync, Live2DLipSyncOptions } from '@proj-airi/model-driver-lipsync'
import type { Profile } from '@proj-airi/model-driver-lipsync/shared/wlipsync'
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/shared-providers'
import type { UnElevenLabsOptions } from 'unspeech'
@@ -127,6 +127,7 @@ const nowSpeaking = ref(false)
const lipSyncStarted = ref(false)
const lipSyncLoopId = ref<number>()
const live2dLipSync = ref<Live2DLipSync>()
const live2dLipSyncOptions: Live2DLipSyncOptions = { mouthUpdateIntervalMs: 50, mouthLerpWindowMs: 50 }
const speechStore = useSpeechStore()
const { ssmlEnabled, activeSpeechProvider, activeSpeechModel, activeSpeechVoice, pitch } = storeToRefs(speechStore)
@@ -252,7 +253,7 @@ async function setupLipSync() {
return
try {
const lipSync = await createLive2DLipSync(audioContext, wlipsyncProfile as Profile)
const lipSync = await createLive2DLipSync(audioContext, wlipsyncProfile as Profile, live2dLipSyncOptions)
live2dLipSync.value = lipSync
connectLipSyncNode(lipSync.node)
await audioContext.resume()