Skip to content

Commit c4d460f

Browse files
justinchubyCopilot
andauthored
Add PersonaPlex / Moshi full-duplex S2S support (Mimi codec + Moshi LM + ORT example) (#368)
Adds end-to-end ONNX support for [`nvidia/personaplex-7b-v1`](https://huggingface.co/nvidia/personaplex-7b-v1), the Kyutai **Moshi** full-duplex speech-to-speech architecture, built declaratively with `onnxscript.nn` from the native Kyutai `safetensors` checkpoints (no HF `config.json`). Three phases, one PR: ### P1 — Mimi neural codec (commit `0fc84c1`) SEANet encoder/decoder + split-RVQ + codec transformer. Encoder codes exact, decode ~2e-7 vs reference. Root cause fixed: Kyutai LayerNorm eps is `1e-5` (mobius default was `1e-6`). ### P2 — Moshi LM (temporal + depformer) - `models/moshi.py`: - **MoshiTemporalModel** — dim=4096, 32L, 32H, RoPE θ=1e4 interleaved, sliding-window causal (3000), SwiGLU via `FusedGateUpMLP`, RMSNorm eps=1e-8. - **MoshiDepformerModel** — dim=1024, 6L, no RoPE, full causal, `weights_per_step=16` per-substep linears; emitted as a **one-substep graph** selected by `substep_index` (Gather), embedding select via `Where` (no `If`), looped 16× externally. - `tasks/_moshi.py`: `MoshiTemporalTask` (`input_frame[B,17,S]` + KV → hidden + text_logits + KV) and `MoshiDepformerTask` (`hidden` + `prev_token` + `substep_index` + KV → logits + KV). - `integrations/moshi`: `build_moshi_lm()` native loader → `{"temporal", "depformer"}` ModelPackages. - **Parity:** exact argmax match vs the Kyutai reference (CPU exact; CUDA exact with `use_tf32=0` — H200 TF32 otherwise flips greedy sampling). Committed golden + generator script; integration parity test (CPU-only) **passes**. ### P3 — ONNX Runtime example - `examples/personaplex_moshi.py`: faithful NumPy port of Kyutai `LMGen.step` — ring cache with per-codebook delays `[0,0,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1]`, temporal step, greedy text sampling, 16-substep autoregressive depformer, delayed output collection — driving four ORT sessions (Mimi encoder/decoder + temporal + depformer) for full-duplex S2S. Verified end to end (5 input frames → 3 assistant frames → decoded waveform). ## Testing - L1 build tests for temporal + depformer pass; full moshi/mimi/codec L1 suite green (9 tests). - Integration parity test passes (`tests/moshi_lm_integration_test.py`, ~280s, CPU). - Example verified end-to-end (build + generate + Mimi decode → `assistant.wav`). - Lint clean on new files. > Note: the temporal/depformer reach mobius only via the native `build_moshi_lm` loader (not the standard `build(model_id)` registry path), consistent with the Mimi codec which also uses a native Kyutai-format loader. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com> Co-authored-by: Justin Chu <11205048+justinchuby@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a49e0f8 commit c4d460f

20 files changed

Lines changed: 4415 additions & 2 deletions

examples/personaplex/README.md

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# PersonaPlex / Moshi — full-duplex speech-to-speech with ONNX Runtime
2+
3+
Real-time, full-duplex voice chat with
4+
[`nvidia/personaplex-7b-v1`](https://huggingface.co/nvidia/personaplex-7b-v1)
5+
(Kyutai **Moshi** architecture) exported to ONNX by `mobius` and run with
6+
`onnxruntime`.
7+
8+
The pipeline is four ONNX sub-models:
9+
10+
```
11+
user audio --> Mimi encoder --> [Moshi temporal + depformer] --> Mimi decoder --> assistant audio
12+
```
13+
14+
* **Mimi encoder** `waveform (B,1,T) -> codes (B,8,Tf)`
15+
* **Moshi temporal** (7B) `frame (B,17,S) -> hidden + text_logits + KV`
16+
* **Moshi depformer** `hidden + prev_token + substep_index + KV -> logits`
17+
(stepped 16× per frame, once per audio codebook)
18+
* **Mimi decoder** `codes (B,8,Tf) -> waveform (B,1,T)`
19+
20+
Each 12.5 Hz frame (1920 samples @ 24 kHz = 80 ms of audio) must be processed
21+
within 80 ms for real time. On an fp16 LM + CUDA GPU the Moshi LM is
22+
~27 ms/frame (~3× headroom); CPU fp32 (~1.8 s/frame) is far too slow for the
23+
streaming/server modes.
24+
25+
## Files
26+
27+
| File | Purpose |
28+
|------|---------|
29+
| `moshi_ort.py` | Builds the ONNX models and runs the generation loop (offline / `--stream` / `--mic`). |
30+
| `server.py` | `aiohttp` WebSocket server for browser-based real-time chat. Loads pre-built models; does **not** import `mobius`. |
31+
| `static/index.html` | Browser client: mic capture + speaker playback at 24 kHz. |
32+
33+
## 1. Build the ONNX models (needs `mobius`)
34+
35+
Build the models once in an environment that has `mobius` installed. For
36+
real-time speed export the Moshi LM in **fp16** (the Mimi codec stays fp32):
37+
38+
```bash
39+
python examples/personaplex/moshi_ort.py \
40+
--device cuda --lm-dtype f16 --frames 1 \
41+
--model-dir output/personaplex/onnx
42+
```
43+
44+
This writes `mimi_encoder/`, `mimi_decoder/`, `temporal/`, `depformer/` under
45+
`--model-dir`. (Graph construction runs on CPU even for `--device cuda`, so no
46+
GPU is needed for the build step.)
47+
48+
## 2. Run the browser server (needs a CUDA GPU for real time)
49+
50+
The server only loads ONNX models, so it can run in a lightweight
51+
`onnxruntime-gpu` environment without `mobius`:
52+
53+
```bash
54+
pip install aiohttp onnxruntime-gpu numpy sentencepiece huggingface_hub
55+
python examples/personaplex/server.py \
56+
--model-dir output/personaplex/onnx --device cuda \
57+
--host 127.0.0.1 --port 7681
58+
```
59+
60+
On Ampere+/H200 GPUs ORT defaults to TF32 for fp32 matmuls, which can flip
61+
greedy sampling; the server uses `use_tf32=0` for fp32 parity (pass
62+
`--allow-tf32` to keep the faster default). TF32 does not affect the fp16 LM.
63+
64+
### Open it in your browser (SSH port-forward)
65+
66+
If the GPU box is remote, forward the port to your laptop and open the page
67+
locally (browsers only grant microphone access on `localhost`/HTTPS):
68+
69+
```bash
70+
ssh -L 7681:localhost:7681 <user>@<gpu-host>
71+
# then open http://localhost:7681 and click "Start"
72+
```
73+
74+
Click **Start session**, then **Start**, allow microphone access, and talk —
75+
you should hear Moshi respond in real time. Only one tab can connect at a time
76+
(single-user demo); a second connection receives a `busy` message.
77+
78+
### Persona + voice customization
79+
80+
Before talking, the page lets you condition the assistant (PersonaPlex
81+
"system prompt" priming, ported from `LMGen.step_system_prompts`):
82+
83+
* **Persona (system prompt):** a text box (defaults to a friendly-teacher
84+
persona). The text is wrapped as `<system> … <system>`, tokenized with the
85+
model's SentencePiece tokenizer (`tokenizer_spm_32k_3.model`, downloaded from
86+
the HF repo on first run), and force-fed on the text stream.
87+
* **Voice sample (optional):** record ~6 s of speech to clone the assistant's
88+
voice. The recording is Mimi-encoded and force-fed on the assistant audio
89+
stream so the model continues in that voice.
90+
91+
Click **Start session** to prime (a couple of seconds), wait for *ready*, then
92+
**Start** to converse. **Restart session** re-primes with new settings.
93+
94+
The server pass-through is:
95+
96+
1. browser connects → server replies `config`
97+
2. browser sends `{"persona": "...", "hasVoice": true|false}` (+ a binary
98+
float32 24 kHz PCM blob if `hasVoice`)
99+
3. server tokenizes the persona, Mimi-encodes the voice, runs the 4-phase
100+
priming, replies `ready`
101+
4. live 1920-sample float32 frames stream both ways
102+
103+
Persona priming is optional: if the tokenizer can't be loaded the server logs a
104+
warning and disables text prompts (voice still works). Point `--tokenizer` at a
105+
local `tokenizer_spm_32k_3.model` to avoid the HF download.
106+
107+
## 3. Offline / terminal modes (no browser)
108+
109+
`moshi_ort.py` also runs standalone:
110+
111+
```bash
112+
# Offline: generate a few frames from silence, save assistant audio
113+
python examples/personaplex/moshi_ort.py --frames 25 --save-to out/personaplex
114+
115+
# Drive with a real input wav as the user stream
116+
python examples/personaplex/moshi_ort.py --audio user.wav --save-to out/personaplex
117+
118+
# Simulated real-time stream from a wav (reports RTF / per-frame budget)
119+
python examples/personaplex/moshi_ort.py --skip-build --device cuda \
120+
--lm-dtype f16 --stream --audio user.wav --model-dir output/personaplex/onnx
121+
122+
# Live full-duplex mic -> speaker (needs `sounddevice` + audio hardware)
123+
python examples/personaplex/moshi_ort.py --skip-build --device cuda --mic \
124+
--model-dir output/personaplex/onnx
125+
```
126+
127+
`--skip-build` reuses an already-exported `--model-dir`.
128+
129+
## Performance reference (H200, fp16 LM + fp32 Mimi, `use_tf32=0`)
130+
131+
`--stream` over 60 frames: RTF ≈ 0.63, per-frame mean 51 / p90 57 / max 65 ms,
132+
0/60 over the 80 ms budget. The WebSocket server measures a similar RTF
133+
(≈ 0.77 including network/scheduling) and reports over-budget frame counts on
134+
client disconnect.
135+
136+
## Notes
137+
138+
* The Moshi LM uses a native Kyutai `safetensors` loader
139+
(`mobius.integrations.moshi.build_moshi_lm`) rather than the standard
140+
`build()` path, because the checkpoint has no HuggingFace `config.json` and
141+
one checkpoint maps to two heterogeneous graphs (temporal + depformer).
142+
* The Mimi codec is built in fp32 by default; pairing an fp16 LM with an fp32
143+
Mimi gives exact codec codes with full real-time headroom.
144+
* `MoshiORT.warmup()` runs a few frames at connect time to absorb the
145+
first-frame CUDA autotune stall (otherwise the first real frame glitches).

0 commit comments

Comments
 (0)