For the complete documentation index, see llms.txt. This page is also available as Markdown.

Lipsync & Blendshape

Drive real-time facial animation on 3D characters using blendshape data streamed alongside the bot's audio.

How it works

  1. Enable lipsync in config — the server starts generating blendshape frames alongside TTS audio.

  2. Frames arrive in chunks of 10 and are buffered in client.blendshapeQueue.

  3. Your render loop reads frames from the queue at 60 fps, synchronized to when the bot is speaking.

  4. When the bot stops speaking, the queue drains and signals conversation end.


Enable lipsync

const client = useConvaiClient({
  apiKey: '...',
  characterId: '...',
  enableLipsync: true,
  blendshapeConfig: {
    format: 'arkit', // or 'mha' (MetaHuman 251, default)
  },
});

Formats (server-verified — an unknown string rejects the connect with the valid list):

format

Frame dim

Channel naming

Notes

"mha" (default)

251

CTRL_expressions_*

Unreal MetaHuman Animation; MetaHuman-Lite characters (103 morphs) consume their named subset of the same stream

"arkit"

61

ARKit names (JawOpen, …)

Apple ARKit standard

"cc4_extended"

170

CC4 ExpressionPlus

Reallusion Character Creator 4

"cc5_hd"

Accepted by the server but currently delivers no frames

"visemes"

15

OVR viseme set

sil, PP, FF, TH, DD, kk, CH, SS, nn, RR, aa, E, ih, oh, ou


Reading frames in the render loop

Access client.blendshapeQueue from your animation loop. The queue is time-indexed at 60 fps.

getFrameAtTime(elapsedSeconds)

Returns the frame closest to the given elapsed time (assuming 60 fps), or null if the queue is empty.

getFrameWithAlpha(index)

Same as getFrame but applies fade-in (first 10 frames) and fade-out (last 10 frames or on interrupt) automatically:


Applying blendshapes to a Three.js mesh

ARKIT_ORDER_61 is an ordered array of the 61 ARKit blendshape names matching the frame indices.

For MetaHuman ("mha" format), import METAHUMAN_ORDER_251 instead.


Custom mappers

Use a custom mapper to transform incoming blendshapes to match your character's morph target names.

ARKit name mapper

The mapper is applied to every frame as it enters the queue.

Direct name-mapped characters (no mapper needed)

If your character's morph targets are named exactly like the stream's channels, skip mappers entirely and write frames by name:

  • format: 'mha' channels are named CTRL_expressions_jawOpen, CTRL_expressions_eyeBlinkL, … — matching MetaHuman-style exports.

  • format: 'arkit' channels use ARKit names (JawOpen, EyeBlinkLeft, …).

Build an index table once per mesh, then apply frames with zero per-frame lookups:

Exports sometimes mangle names (e.g. a missing separator: CTRL_expressionsjawOpen). Normalize morphTargetDictionary keys at load rather than adjusting every consumer.

Set mapper after connect


BlendshapeQueue API

client.blendshapeQueue is the live queue instance.

State checks

Frame access

Consumption

Turn stats

Interruption

Debug


Making lipsync feel natural

Patterns proven on production characters — each one addresses a specific artifact you will otherwise see:

Fade the stream in and out

Raw frames hit the face at full strength on the first frame and freeze the last viseme when frames stop. Scale every stream-driven value by an eased envelope: bloom in over ~0.25 s at speech start, settle out over ~0.5 s after the last frame (keep the final frame and fade it to zero — don't drop it mid-shape).

(getFrameWithAlpha does this automatically if you consume the queue directly; apply your own envelope when you buffer/apply frames yourself.)

Let procedural systems own their channels

If you run procedural blinking or camera-following gaze, the stream must never write the eye channels — two writers on different timers race each other into flicker (blinks never visibly close). Keep a skip mask of channel indices the stream leaves untouched, and let your blink/gaze code own them outright.

End-of-speech detection

Frames can stop arriving before any explicit end event. Treat ~300 ms without a new frame as end-of-speech and start your fade-out — the official end signal can then arrive late without a visible freeze.

Per-channel gains

Streams are tuned for a reference face; your character's shapes may read too strong. A per-channel gain table fixes this surgically — e.g. on one MetaHuman-style rig we ship jawOpen × 0.7 and lateral jaw/mouth shifts × 0 (they read as lopsidedness), leaving everything else at 1.

Mouth symmetry

If the stream drives L/R mouth pairs unevenly (we've measured up to 1.5× side-to-side), average each sided pair (…L/…R) before applying — an open mouth reads far better symmetric.

Apply after your animation mixer

If body animation clips also touch the head, re-apply the current lipsync frame after the mixer updates each render frame so speech always wins on the face.


Buffer tuning

The frames_buffer_duration option controls how many seconds of blendshapes the server accumulates before releasing them with the audio. Higher values increase lipsync accuracy at the cost of latency.

Last updated

Was this helpful?