# Ppaso-TTS Lipsync — companion animation module > 한국어: [README_lipsync_ko.md](./README_lipsync_ko.md) · Main: [README.md](./README.md) > > Blendshape mapping detail: [README_bs2d.md](./README_bs2d.md) Pure-numpy companion module **`ppaso_lipsync`** (in `example/`) turns Ppaso-TTS output into per-frame **2D mouth + 1D eye-blink animation curves**. A single lipsync curve drives **two render routes**: - **atlas** — 2D sprite swap (nearest viseme → one mouth sprite). `07_avatar_video.py` - **blendshape** — 3D morph-target blend via `bs2d`. `08_vrm_video.py` ## Demo **Blendshape — 3D VRM, lipsync + body idle** (`samples/08_vrm_lipsync.mp4`, [download](https://huggingface.co/akamotaco/ppaso-tts-v1/resolve/main/samples/08_vrm_lipsync.mp4)): **Atlas — 2D sprite** (`samples/06_lipsync_intro.mp4`, [download](https://huggingface.co/akamotaco/ppaso-tts-v1/resolve/main/samples/06_lipsync_intro.mp4)): > 엣지디바이스용 경량화 립싱크 애니메이션 기능. 동일한 커브가 2D 스프라이트와 > 3D 블렌드쉐입 애니메이션을 모두 구동합니다. > *(One curve drives both 2D sprite and 3D blendshape animation.)* ## How it works ``` TTS phones + durations │ ▼ viseme decompose ← 9 anchor (A/I/U/E/O/EU/EO/M/sil) + j/w glide, │ palatalized / alveolar / sibilant / velar / glottal ▼ keyframes → 2D xy curve ← shared lipsync curve, x∈[-1,1] y∈[-0.1,1] │ ├──────────► atlas route : nearest viseme → sprite swap │ └──────────► blendshape route : bs2d → barycentric → morph blend Eye blink (independent) → state_timeline {0:open,1:half,2:close} + curve_timeline [0,1] ``` The lipsync **curve generation** (decompose → keyframes → interpolate) is shared. The two routes differ only in how they consume the `(x, y)` curve — see the atlas/blendshape distinction below. ## Route A — atlas (2D sprite) Each frame the curve point picks the **nearest viseme** and the renderer shows that **one** mouth sprite (sprites cannot blend). Best for 2D character art with a sprite sheet. ```bash cd example python 07_avatar_video.py --text "안녕하세요." --out hi.mp4 ``` - Sample 2D avatar: `example/avatar/` (atlas.json + sprite sheet). - Curve → sprite mapping: `atlas.json` `viseme_to_sprite`. ## Route B — blendshape (3D VRM) For 3D characters with **morph-target (blendshape) visemes**. Here the curve goes through **`bs2d`**, which finds the triangle containing the point and returns **barycentric blend weights** — so 3 blendshapes are mixed per frame, giving continuous mouth motion (a sprite swap cannot do this). ``` lipsync (x,y) curve → bs2d(target="vrm") → {a,i,u,e,o,neutral} weight → VRM blendShapeMaster morph → mesh deform → render ``` - **vrm-spec** — bs2d maps the curve onto a standard VRM 0.x avatar's 5 viseme + neutral expressions (Korean ㅏㅣㅜㅔㅗ → VRM a/i/u/e/o). - Three render backends (`--backend`): - **gles** (default) — EGL + OpenGL ES 3.0. Real GPU acceleration on ARM SoCs (RK3576 Mali-G52 etc.). Needs `PyOpenGL`. - **egl** — moderngl + EGL desktop GL 3.3. For x86 GPUs / Mesa llvmpipe; ARM GPU drivers usually cap desktop GL at 3.1 so moderngl can't run there — use `gles` on-device. Needs `moderngl` + `glcontext`. - **numpy** — pure-numpy software rasterizer, zero GPU/driver dependency (slow — fallback only). - Bundled sample VRM: `example/avatar_vrm/AvatarSample_F.vrm` (VRoid 'Vita', CC0). ```bash cd example python 08_vrm_video.py --text "안녕하세요." --out hi.mp4 # gles (default) python 08_vrm_video.py --backend egl # x86 desktop GPU python 08_vrm_video.py --backend numpy # no-GPU fallback ``` > **bs2d full-spec ↔ vrm-spec, the projection / barycentric stages, and how > to map your own character's blendshapes (9 or more) — see > [README_bs2d.md](./README_bs2d.md).** ## Body animation (optional) `09_vrm_live.py` and `10_vrm_live_streaming.py` add a skeletal **body idle** on top of lipsync — GPU skinning via `raster_gles_skinned`. It needs a body **clip** (`--idle clip.npz`); **without one the body stays at rest pose** (lipsync and video still render fine). Make a clip from any FBX skeletal animation with `tools/fbx_to_clip.py` (needs `bpy` — `pip install bpy`): ```bash python tools/fbx_to_clip.py idle.fbx avatar_vrm/idle.npz python 09_vrm_live.py --idle avatar_vrm/idle.npz ``` The clip is **not bundled** — animation assets such as Mixamo are royalty-free to embed in your rendered output, but their raw motion data is generally not redistributable, so supply your own. `BodyClipPlayer` assumes a loop-authored clip; `fbx_to_clip.py` drops the duplicate loop endpoint. ## Quick start ```python from ppaso_tts import PpasoTTS from ppaso_lipsync import generate_blink_timeline tts = PpasoTTS('./', backend='onnx') # or 'rknn' wav, lipsync = tts.synthesize_with_lipsync('안녕하세요.', fps=30) # lipsync.xy_timeline → (n_frames, 2) — feeds atlas or blendshape route # lipsync.keyframes → viseme keyframes (debug) blink = generate_blink_timeline(duration=lipsync.duration, fps=30, seed=42) # blink.state_timeline / blink.curve_timeline ``` Streaming (chunk-to-chunk continuity is automatic): ```python from ppaso_tts import PpasoTTS, StreamingTTS stream = StreamingTTS(PpasoTTS('./', backend='rknn')) for token in llm_token_stream(): for wav, lipsync in stream.feed_with_lipsync(token): # emits on .!? play_audio(wav); animate(lipsync) for wav, lipsync in stream.flush_with_lipsync(): ... ``` ## Performance ### Atlas route — RK3576 measured End-to-end (TTS + lipsync + eye blink + sprite composite at 768×1152, no ffmpeg encode). RK3576, best-of-5 @ 30 fps. | Case | Input | wav | Backend | E2E | RTF | Speed | |--- |--- |--- |--- |--- |--- |--- | | short | 21 chars | 3.49 s | **RK3576 NPU** | **864 ms** | 0.247 | **4.0× RT** | | | | | ONNX CPU (4t) | 897 ms | 0.256 | 3.9× RT | | medium | 41 chars | 7.26 s | **RK3576 NPU** | **1867 ms** | 0.257 | **3.9× RT** | | long | 110 chars| 16.65 s | **RK3576 NPU** | **3668 ms** | 0.220 | **4.5× RT** | > Reproduce: `cd example && python bench_lipsync.py --backend rknn --model-dir ..`. > Lipsync curve generation itself adds < 1 ms on top of TTS. ### Blendshape route — RK3576 measured End-to-end (TTS + lipsync + eye blink + morph + render at 512×768, no ffmpeg encode — **same E2E definition as the atlas route above**). RK3576 Mali-G52, **gles** backend, best-of-3 @ 30 fps. Two modes: **lipsync only** (static skeleton — `08_vrm_video.py`) and **lipsync + body** (GPU skinning + body idle animation — `09_vrm_live.py`). **① lipsync only — E2E by text length:** | Case | wav | frames | E2E | RTF | |--- |--- |--- |--- |--- | | short | 3.51 s | 110 | **2222 ms** | **0.63** | | medium | 7.26 s | 223 | **4637 ms** | **0.64** | | long | 16.31 s | 497 | **9358 ms** | **0.57** | **② lipsync + body — E2E by text length:** | Case | wav | frames | E2E | RTF | |--- |--- |--- |--- |--- | | short | 3.51 s | 110 | **2809 ms** | **0.80** | | medium | 7.26 s | 223 | **5580 ms** | **0.77** | | long | 16.31 s | 497 | **12314 ms** | **0.76** | **③ lipsync + body — medium (7.26 s, 223 frames) stage breakdown:** | Stage | medium | |--- |--- | | TTS + lipsync | 1100 ms | | Eye blink | 0.5 ms | | FK + pose (skeleton) | 1031 ms (4.6 ms/frame) | | Morph (bs2d weight) | 19 ms | | Render (GPU skinning) | 3430 ms (15.4 ms/frame) | | **E2E** | **5580 ms · RTF 0.77** | > Reproduce: `cd example && python bench_vrm.py --backend gles --model-dir ..` > (lipsync only) — add `--body ` for lipsync + body (see > *Body animation* above for making a clip). > > **lipsync only** sits at **RTF ≈ 0.6**, **lipsync + body** at > **RTF ≈ 0.77** — both comfortably faster than realtime. Body animation > adds only the CPU **FK + pose** stage (~4.6 ms/frame): the skinning > itself runs in the vertex shader (GPU), so render cost is essentially > unchanged (~15 ms/frame). Render still dominates (~60–70 % of E2E) and > fits the 33 ms budget of 30 fps, so a streaming setup (TTS chunk ∥ > render pipeline) stays realtime. The atlas route is lighter still > (RTF ~0.25): 2D sprite paste vs full 3D mesh rasterization. > > Other backends (RK3576, lipsync only): **numpy** — pure-CPU rasterizer, > ~11 s/frame (RTF ~345), a no-GPU fallback for stills only. **egl** > (moderngl) — needs desktop GL 3.3, which ARM GPU drivers cap at 3.1 > (Panfrost); x86 GPU / Mesa llvmpipe only, does not run on RK3576. ## Coverage / Limitations - **Korean MFA G2P compatible** — covers the phones the Ppaso G2P emits. - **~11 unmapped phones out of 118** (loanword `f/v/z`, rare `cʰ/dʑ/ʝ/ʁ`, glide `ɥ`, labialized `ɾʷ`). - **Decomposition ratios** (j-glide 30:70, w-glide 25:75) are a first pass. - **VRM route** — VRM 0.x only; standard `blendShapeMaster` viseme presets expected. VRM has no dedicated closed-lip viseme (see README_bs2d.md). ## Resources - **Lipsync module**: `example/ppaso_lipsync/` — visemes / extractor / keyframes / interpolate / eyes / **bs2d** / pipeline. API docs: [`example/ppaso_lipsync/README.md`](./example/ppaso_lipsync/README.md) - **VRM render module**: `example/ppaso_vrm/` — loader / scene / anim / anim_body (skeleton) / raster (numpy + egl/moderngl + gles + gles_skinned). - **bs2d mapping detail**: [README_bs2d.md](./README_bs2d.md) - **Examples**: `07_avatar_video.py` (atlas) · `08_vrm_video.py` (blendshape) · `09_vrm_live.py` (blendshape + body) · `10_vrm_live_streaming.py` (streaming + body) · `06_streaming_with_lipsync.py` · `bench_lipsync.py` (atlas) · `bench_vrm.py` (blendshape) - **Body-clip tool**: `example/tools/fbx_to_clip.py` — FBX → `.npz` body clip - **Sample assets**: `example/avatar/` (2D) · `example/avatar_vrm/` (3D VRM) ## License Apache 2.0 — same as Ppaso-TTS main.