Skip to main content
The offline path is the SDK’s most powerful pattern: run inference once, persist the result as JSON, and replay it forever without touching the model, the network, or a license refresh. It is the right tool for prefetching, queued playback, deterministic video export, and any case where the same audio is animated more than once. The artifact is the VisemeTimeline — plain, versioned JSON.

Generate → persist → replay

1

Generate once

Run processAudio (vanilla) or useProcessAudio (React). result.timeline is the artifact.
2

Persist anywhere

It is plain JSON — localStorage, your database, a CDN object, a file.
3

Replay with zero reprocessing

parseTimeline(JSON.parse(stored))playback.setTimeline(...)playback.play(). No inference, no streaming session, no network.

React

Vanilla

Assembling a timeline yourself

If you already have per-frame viseme ids (e.g. from your own batch job), build a timeline with the pure converters instead of running inference:

Prefetching & queues

Because a timeline is detached from the model, you can compute many ahead of time and play them instantly later:
  • Prefetch on idle — generate timelines for likely-next utterances during idle time; play from cache the moment they are needed (no inference latency at play time).
  • Queue playback — store a list of { audioUrl, timeline } pairs; for each, start the audio and playback.setTimeline(timeline) in lockstep.
  • Server-side precompute — generate timelines in a build step or backend job, ship the JSON with your assets, and the client never runs inference for that content at all.
speechMs rides inside the timeline, so cached replay never re-meters and never re-infers.

Deterministic video export

For frame-accurate rendering (recording the avatar to video), a timeline gives you a fixed, inspectable script: the same JSON produces the same mouth frames every run. Drive playback.seek(ms) to a render clock instead of wall-clock playback, capture the canvas per frame, and mux against the original audio. Because there is no live inference in the loop, export is reproducible and as fast as your renderer.

Versioning & the trust boundary

parseTimeline validates untrusted/persisted JSON and throws a LipsyncError with .code === "bad_timeline" on a version or shape mismatch — so a stale stored timeline fails loudly instead of animating garbage:
Always load persisted timelines through parseTimeline, never JSON.parse alone. VISEME_TIMELINE_VERSION bumps on breaking changes; old artifacts are rejected deterministically.

Next

Visemes & the timeline

The timeline format in detail.

Core client

processAudio and helpers.

Error codes

bad_timeline and the rest.