feat(model-driver-lipsync): new package for model driving capabilities

This commit is contained in:
Neko Ayaka
2025-12-15 02:16:10 +08:00
parent bd357c924c
commit 08cab8515d
14 changed files with 247 additions and 27 deletions
@@ -157,7 +157,7 @@ export function setupWidgetsWindowManager(): WidgetsWindowManager {
}
function toSnapshot(record: WidgetRecord): WidgetSnapshot {
const { timer, ...snapshot } = record
const { timer: _timer, ...snapshot } = record
return snapshot
}
+1
View File
@@ -141,6 +141,7 @@ words:
- libsamplerate
- libsodium
- lightningcss
- lipsync
- listhen
- live2dcubismcore
- live2dcubismframework
@@ -0,0 +1,15 @@
{
"name": "@proj-airi/model-driver-lipsync",
"type": "module",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts",
"./live2d": "./src/live2d/index.ts",
"./shared/wlipsync": "./src/shared/wlipsync/index.ts",
"./shared/wlipsync/profile.json": "./src/shared/wlipsync/profile.json"
},
"dependencies": {
"wlipsync": "^1.3.0"
}
}
@@ -0,0 +1 @@
export * from './live2d'
@@ -0,0 +1,105 @@
import type { Profile } from 'wlipsync'
import { createWLipSyncNode } from 'wlipsync'
const RAW_KEYS = ['A', 'E', 'I', 'O', 'U', 'S'] as const
const RAW_TO_VOWEL: Record<typeof RAW_KEYS[number], VowelKey> = {
A: 'A',
E: 'E',
I: 'I',
O: 'O',
U: 'U',
// Treat S as silence/closed; map to a small I-like mouth to avoid a hard snap
S: 'I',
}
export type VowelKey = 'A' | 'E' | 'I' | 'O' | 'U'
export interface Live2DLipSync {
/**
* The underlying wLipSync AudioWorkletNode. Connect your audio source to it.
*/
node: Awaited<ReturnType<typeof createWLipSyncNode>>
/**
* Get per-vowel weights (already remapped from AEIOUS to AEIOU) scaled by current volume.
*/
getVowelWeights: () => Record<VowelKey, number>
/**
* Get a single mouth-open value (0-1) derived from the loudest vowel weight.
*/
getMouthOpen: () => number
/**
* Convenience helper to connect an audio source node to the lip sync node.
*/
connectSource: (source: AudioNode) => void
}
export interface Live2DLipSyncOptions {
/**
* Overall cap for each vowel weight after scaling.
* Defaults to 0.7 to keep Live2D mouth movement natural.
*/
cap?: number
/**
* Volume multiplier applied before exponent.
* Defaults to 0.9.
*/
volumeScale?: number
/**
* Exponent for the volume curve to soften peaks.
* Defaults to 0.7.
*/
volumeExponent?: number
}
/**
* Create a Live2D-friendly lip sync helper using the wLipSync worklet.
* - Compute AEIOUS weights from the worklet
* - Remap to AEIOU
* - Scale by volume to derive a mouth-open value
*/
export async function createLive2DLipSync(
audioContext: AudioContext,
profile: Profile,
options: Live2DLipSyncOptions = {},
): Promise<Live2DLipSync> {
const node = await createWLipSyncNode(audioContext, profile)
const cap = options.cap ?? 0.7
const volumeScale = options.volumeScale ?? 0.9
const volumeExponent = options.volumeExponent ?? 0.7
const getVowelWeights = (): Record<VowelKey, number> => {
const projected: Record<VowelKey, number> = { A: 0, E: 0, I: 0, O: 0, U: 0 }
const amp = Math.min((node.volume ?? 0) * volumeScale, 1) ** volumeExponent
for (const raw of RAW_KEYS) {
const vowel = RAW_TO_VOWEL[raw]
const rawVal = node.weights?.[raw] ?? 0
projected[vowel] = Math.max(projected[vowel], Math.min(cap, rawVal * amp))
}
return projected
}
const getMouthOpen = () => {
const weights = Object.values(getVowelWeights())
return weights.length ? Math.max(...weights) : 0
}
const connectSource = (source: AudioNode) => {
try {
source.connect(node)
}
catch (error) {
console.error('[model-driver-lipsync] failed to connect source to lip sync node', error)
}
}
return {
node,
getVowelWeights,
getMouthOpen,
connectSource,
}
}
@@ -0,0 +1,2 @@
export { default as wlipsyncProfile } from './profile.json' assert { type: 'json' }
export type { Profile } from 'wlipsync'
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext", "DOM"],
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
+1
View File
@@ -73,6 +73,7 @@
"@proj-airi/font-cjkfonts-allseto": "workspace:^",
"@proj-airi/font-xiaolai": "workspace:^",
"@proj-airi/i18n": "workspace:^",
"@proj-airi/model-driver-lipsync": "workspace:^",
"@proj-airi/server-sdk": "workspace:^",
"@proj-airi/stage-shared": "workspace:^",
"@proj-airi/stage-ui-three": "workspace:^",
@@ -1,5 +1,7 @@
<script setup lang="ts">
import type { DuckDBWasmDrizzleDatabase } from '@proj-airi/drizzle-duckdb-wasm'
import type { Live2DLipSync } 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'
@@ -9,6 +11,8 @@ import type { TTSChunkItem } from '../../utils/tts'
import { drizzle } from '@proj-airi/drizzle-duckdb-wasm'
import { getImportUrlBundles } from '@proj-airi/drizzle-duckdb-wasm/bundles/import-url-browser'
import { createLive2DLipSync } from '@proj-airi/model-driver-lipsync'
import { wlipsyncProfile } from '@proj-airi/model-driver-lipsync/shared/wlipsync'
import { ThreeScene, useModelStore } from '@proj-airi/stage-ui-three'
import { animations } from '@proj-airi/stage-ui-three/assets/vrm'
import { useBroadcastChannel } from '@vueuse/core'
@@ -67,13 +71,13 @@ const { textSegmentationQueue } = storeToRefs(textSegmentationStore)
clearTextSegmentationHooks()
const characterSpeechPlaybackQueue = usePipelineCharacterSpeechPlaybackQueueStore()
const { connectAudioContext, connectAudioAnalyser, clearAll, onPlaybackStarted, onPlaybackFinished } = characterSpeechPlaybackQueue
const { connectAudioContext, connectAudioAnalyser, connectLipSyncNode, clearAll, onPlaybackStarted, onPlaybackFinished } = characterSpeechPlaybackQueue
const { currentAudioSource, playbackQueue } = storeToRefs(characterSpeechPlaybackQueue)
const settingsStore = useSettings()
const { stageModelRenderer, stageViewControlsEnabled, live2dDisableFocus, stageModelSelectedUrl, stageModelSelected } = storeToRefs(settingsStore)
const { mouthOpenSize } = storeToRefs(useSpeakingStore())
const { audioContext, calculateVolume } = useAudioContext()
const { audioContext } = useAudioContext()
connectAudioContext(audioContext)
const { onBeforeMessageComposed, onBeforeSend, onTokenLiteral, onTokenSpecial, onStreamEnd, onAssistantResponseEnd, clearHooks } = useChatStore()
@@ -121,6 +125,8 @@ vrmStore.onShouldUpdateView(async () => {
const audioAnalyser = ref<AnalyserNode>()
const nowSpeaking = ref(false)
const lipSyncStarted = ref(false)
const lipSyncLoopId = ref<number>()
const live2dLipSync = ref<Live2DLipSync>()
const speechStore = useSpeechStore()
const { ssmlEnabled, activeSpeechProvider, activeSpeechModel, activeSpeechVoice, pitch } = storeToRefs(speechStore)
@@ -224,20 +230,39 @@ onTextSegmented((chunkItem) => {
ttsQueue.enqueue(chunkItem)
})
function getVolumeWithMinMaxNormalizeWithFrameUpdates() {
requestAnimationFrame(getVolumeWithMinMaxNormalizeWithFrameUpdates)
if (!nowSpeaking.value)
function startLipSyncLoop() {
if (lipSyncLoopId.value)
return
mouthOpenSize.value = calculateVolume(audioAnalyser.value!, 'linear')
const tick = () => {
if (!nowSpeaking.value || !live2dLipSync.value) {
mouthOpenSize.value = 0
}
else {
mouthOpenSize.value = live2dLipSync.value.getMouthOpen()
}
lipSyncLoopId.value = requestAnimationFrame(tick)
}
lipSyncLoopId.value = requestAnimationFrame(tick)
}
function setupLipSync() {
if (!lipSyncStarted.value) {
getVolumeWithMinMaxNormalizeWithFrameUpdates()
audioContext.resume()
async function setupLipSync() {
if (lipSyncStarted.value)
return
try {
const lipSync = await createLive2DLipSync(audioContext, wlipsyncProfile as Profile)
live2dLipSync.value = lipSync
connectLipSyncNode(lipSync.node)
await audioContext.resume()
startLipSyncLoop()
lipSyncStarted.value = true
}
catch (error) {
lipSyncStarted.value = false
console.error('Failed to setup Live2D lip sync', error)
}
}
function setupAnalyser() {
@@ -250,7 +275,7 @@ function setupAnalyser() {
onBeforeMessageComposed(async () => {
clearAll()
setupAnalyser()
setupLipSync()
await setupLipSync()
// Reset assistant caption for a new message
assistantCaption.value = ''
postCaption({ type: 'caption-assistant', text: '' })
@@ -303,11 +328,24 @@ function canvasElement() {
return vrmViewerRef.value?.canvasElement()
}
onUnmounted(() => {
if (lipSyncLoopId.value) {
cancelAnimationFrame(lipSyncLoopId.value)
lipSyncLoopId.value = undefined
}
})
defineExpose({
canvasElement,
})
onPlaybackFinished(() => {
nowSpeaking.value = false
mouthOpenSize.value = 0
})
onPlaybackStarted(({ text }) => {
nowSpeaking.value = true
// NOTICE: currently, postCaption, postPresent from useBroadcastChannel may throw error
// once we navigate away from the page that created the BroadcastChannel,
// as the channel gets closed on unmount, leading to "Failed to execute 'postMessage' on 'BroadcastChannel': The channel is closed."
+16 -9
View File
@@ -126,6 +126,7 @@ export const usePipelineCharacterSpeechPlaybackQueueStore = defineStore('pipelin
const audioContext = shallowRef<AudioContext>()
const audioAnalyser = shallowRef<AnalyserNode>()
const lipSyncNode = shallowRef<AudioNode>()
function connectAudioContext(context: AudioContext) {
audioContext.value = context
@@ -135,6 +136,10 @@ export const usePipelineCharacterSpeechPlaybackQueueStore = defineStore('pipelin
audioAnalyser.value = analyser
}
function connectLipSyncNode(node: AudioNode) {
lipSyncNode.value = node
}
function clearPlaying() {
if (currentAudioSource) {
try {
@@ -172,6 +177,9 @@ export const usePipelineCharacterSpeechPlaybackQueueStore = defineStore('pipelin
source.connect(audioContext.value.destination)
// Connect the source to the analyzer
source.connect(audioAnalyser.value!)
// Connect to lip sync tap if provided
if (lipSyncNode.value)
source.connect(lipSyncNode.value)
// Start playing the audio
for (const hook of onPlaybackStartedHooks.value) {
@@ -192,15 +200,13 @@ export const usePipelineCharacterSpeechPlaybackQueueStore = defineStore('pipelin
currentAudioSource.value = source
source.start(0)
source.onended = () => {
// Play special token: delay or emotion
if (ctx.data.special) {
for (const hook of onPlaybackFinishedHooks.value) {
try {
hook({ special: ctx.data.special })
}
catch (err) {
console.error('Error in onPlaybackFinished hook:', err)
}
// Notify hooks regardless; consumers can decide how to use the special token (if any).
for (const hook of onPlaybackFinishedHooks.value) {
try {
hook({ special: ctx.data.special ?? '' })
}
catch (err) {
console.error('Error in onPlaybackFinished hook:', err)
}
}
@@ -230,6 +236,7 @@ export const usePipelineCharacterSpeechPlaybackQueueStore = defineStore('pipelin
connectAudioContext,
connectAudioAnalyser,
connectLipSyncNode,
clearPlaying,
clearQueue,
clearAll,
@@ -80,7 +80,7 @@ function eventListenerOf(type: string, listener: EventListenerOrEventListenerObj
}
}
async function startRealtimeSession(options: InternalRealtimeOptions): Promise<void> {
async function startRealtimeSession(options: InternalRealtimeOptions): Promise<AliyunStreamTranscriptionHandle> {
const {
accessKeyId,
accessKeySecret,
@@ -103,11 +103,13 @@ async function startRealtimeSession(options: InternalRealtimeOptions): Promise<v
const websocket = new WebSocket(url)
websocket.binaryType = 'arraybuffer'
const abortHandler = eventListenerOf('abort', () => cleanup(abortSignal?.reason ?? new DOMException('Aborted', 'AbortError')), abortSignal)
abortSignal && abortHandler.on()
const abortHandler = abortSignal
? eventListenerOf('abort', () => cleanup(abortSignal?.reason ?? new DOMException('Aborted', 'AbortError')), abortSignal)
: undefined
abortHandler?.on()
async function cleanup(error?: unknown) {
abortHandler && abortSignal && abortHandler.off()
abortHandler?.off()
mayThrow(async () => await reader.cancel())
if (websocket) {
@@ -123,6 +125,10 @@ async function startRealtimeSession(options: InternalRealtimeOptions): Promise<v
await onSessionTerminated?.(error)
}
const handle: AliyunStreamTranscriptionHandle = {
close: async () => await cleanup(new DOMException('Closed', 'AbortError')),
}
async function onTranscriptionStarted() {
try {
while (true) {
@@ -188,6 +194,8 @@ async function startRealtimeSession(options: InternalRealtimeOptions): Promise<v
if (abortSignal?.aborted)
throw abortSignal.reason ?? new DOMException('Aborted', 'AbortError')
return handle
}
export function createAliyunNLSProvider(
@@ -240,6 +248,16 @@ export function createAliyunNLSProvider(
controller.enqueue(encodeSSE({ delta: text, type: 'transcript.text.delta' }))
controller.enqueue(encodeSSE({ delta: '', type: 'transcript.text.done' }))
},
}).then((handle) => {
sessionHandle = handle
}).catch(async (error) => {
controllerClosed = true
try {
await extraOptions?.onSessionTerminated?.(error)
}
finally {
controller.error(error instanceof Error ? error : new Error(String(error)))
}
})
},
cancel: async () => {
+10 -2
View File
@@ -34,7 +34,11 @@ async function publish() {
{
// eslint-disable-next-line no-console
console.log('\nPublish to VSCE...\n')
const execPublish = exec('pnpx', ['@vscode/vsce', 'publish', '--no-dependencies', '-p', process.env.VSCE_TOKEN!, ...[(isPreview ? '--pre-release' : '')]], { nodeOptions: { cwd: root } })
const vsceArgs = ['@vscode/vsce', 'publish', '--no-dependencies', '-p', process.env.VSCE_TOKEN!]
if (isPreview)
vsceArgs.push('--pre-release')
const execPublish = exec('pnpx', vsceArgs, { nodeOptions: { cwd: root } })
for await (const line of execPublish) {
// eslint-disable-next-line no-console
console.log(line)
@@ -43,7 +47,11 @@ async function publish() {
{
// eslint-disable-next-line no-console
console.log('\nPublish to OVSE...\n')
const execPublish = exec('pnpx', ['ovsx', 'publish', '--no-dependencies', '-p', process.env.OVSX_TOKEN!, ...[(isPreview ? '--pre-release' : '')]], { nodeOptions: { cwd: root } })
const ovseArgs = ['ovsx', 'publish', '--no-dependencies', '-p', process.env.OVSX_TOKEN!]
if (isPreview)
ovseArgs.push('--pre-release')
const execPublish = exec('pnpx', ovseArgs, { nodeOptions: { cwd: root } })
for await (const line of execPublish) {
// eslint-disable-next-line no-console
console.log(line)
+9
View File
@@ -1183,6 +1183,12 @@ importers:
specifier: ^7.2.7
version: 7.2.7(@types/node@24.10.4)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
packages/model-driver-lipsync:
dependencies:
wlipsync:
specifier: ^1.3.0
version: 1.3.0
packages/ccc:
dependencies:
meta-png:
@@ -1439,6 +1445,9 @@ importers:
'@proj-airi/audio':
specifier: workspace:^
version: link:../audio
'@proj-airi/model-driver-lipsync':
specifier: workspace:^
version: link:../model-driver-lipsync
'@proj-airi/ccc':
specifier: workspace:^
version: link:../ccc