Skip to content

Commit fadc7bb

Browse files
justinchubyCopilot
andcommitted
Add examples/personaplex.py — file-based PersonaPlex inference demo
A focused, microphone-free example specifically for testing nvidia/personaplex-7b-v1. Reuses MoshiOnnxPipeline from moshi_realtime.py for the actual ORT plumbing but drops the streaming audio I/O complexity: - Loads an audio file (any sample rate; resampled to 24 kHz with librosa) and Mimi-encodes per 80 ms frame. - Falls back to all-zero codes when the Mimi codec isn't installed so the script still smoke-tests every ONNX sub-model. - --synthetic flag uses deterministic non-zero random codes, confirming the pipeline produces frame-varied output (the all-zero-input case correctly produces constant output, which is not as informative). - --save-to builds + exports from HuggingFace; --onnx-dir reloads. - Best-effort text decode via the HF tokenizer at the end. Verified end-to-end on testdata/652-129742-0006.flac (without Mimi: constant outputs as expected for silence-input) and with --synthetic (per-step varied outputs). KV caches (32 decoder + 6 depformer layer pairs) update correctly across steps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
1 parent 4107b3d commit fadc7bb

1 file changed

Lines changed: 297 additions & 0 deletions

File tree

examples/personaplex.py

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
#!/usr/bin/env python
2+
# Copyright (c) Microsoft Corporation.
3+
# Licensed under the MIT License.
4+
5+
"""PersonaPlex: file-based speech-to-text/speech demo via ONNX.
6+
7+
PersonaPlex (``nvidia/personaplex-7b-v1``) is NVIDIA's fine-tune of
8+
Kyutai's Moshi full-duplex speech model. It shares Moshi's 3-model
9+
architecture (embedding + decoder + audio_decoder) and uses the Mimi
10+
audio codec for input and output.
11+
12+
Unlike :file:`moshi_realtime.py`, this script processes an audio file
13+
end-to-end rather than driving a microphone. It is the simplest way to
14+
smoke-test the exported PersonaPlex ONNX models against a known input
15+
without setting up live audio I/O.
16+
17+
Pipeline (per 80 ms frame at 24 kHz)::
18+
19+
PCM frame ─► Mimi encoder ─► audio_codes (16 ints)
20+
21+
22+
text_token + audio_codes ─► embedding ─► inputs_embeds
23+
24+
25+
inputs_embeds ─► decoder ─► text_logits
26+
27+
28+
backbone_hidden ─► audio_decoder ─► output_codes
29+
30+
▼ (optional)
31+
Mimi decoder ─► output PCM
32+
33+
Usage::
34+
35+
# First-time: build + cache ONNX from HuggingFace, then run inference
36+
python examples/personaplex.py --audio testdata/652-129742-0006.flac \
37+
--save-to /tmp/personaplex/
38+
39+
# Subsequent runs: load pre-exported ONNX
40+
python examples/personaplex.py --audio my_speech.wav \
41+
--onnx-dir /tmp/personaplex/
42+
43+
# Synthetic input (no audio file or Mimi codec needed) — fastest sanity
44+
python examples/personaplex.py --synthetic
45+
46+
Notes:
47+
- Mimi codec is optional. Without it, the script falls back to
48+
synthetic all-zero audio codes (still exercises every ONNX sub-model
49+
and validates output token shapes).
50+
- The 7B decoder needs ~28 GB host RAM in float32. Use ``--dtype bf16``
51+
with a recent ORT to reduce that to ~14 GB if you have GPU EP.
52+
- Output audio synthesis (--out-audio) requires the Mimi codec.
53+
"""
54+
55+
from __future__ import annotations
56+
57+
import argparse
58+
import sys
59+
from pathlib import Path
60+
61+
import numpy as np
62+
63+
# Defaults matching nvidia/personaplex-7b-v1
64+
SAMPLE_RATE = 24_000 # Mimi codec sample rate (Hz)
65+
FRAME_SAMPLES = 1920 # 80 ms at 24 kHz (one model step)
66+
NUM_CODEBOOKS = 16 # PersonaPlex/Moshi codebook count
67+
STEPS_PER_SECOND = SAMPLE_RATE // FRAME_SAMPLES # 12.5 Hz
68+
69+
70+
# --------------------------------------------------------------------------- #
71+
# Build / load ONNX
72+
# --------------------------------------------------------------------------- #
73+
74+
75+
def build_and_save(model_id: str, save_dir: Path, dtype: str) -> dict[str, Path]:
76+
"""Export the 3 PersonaPlex sub-models with external-data sidecars."""
77+
import onnx_ir # type: ignore[import-not-found]
78+
79+
from mobius import build # type: ignore[import-not-found]
80+
81+
print(f"[personaplex] Building ONNX from {model_id} (dtype={dtype}) …")
82+
pkg = build(model_id, dtype=dtype)
83+
save_dir.mkdir(parents=True, exist_ok=True)
84+
paths: dict[str, Path] = {}
85+
for name, model in pkg.items():
86+
out = save_dir / f"{name}.onnx"
87+
# External data because the 7B decoder exceeds the 2 GB protobuf
88+
# serialization limit by a wide margin.
89+
onnx_ir.save(model, out, external_data=f"{name}.data")
90+
paths[name] = out
91+
print(f"[personaplex] Saved {name}{out}")
92+
return paths
93+
94+
95+
def resolve_onnx_paths(args: argparse.Namespace) -> dict[str, Path]:
96+
"""Return paths to the three ONNX sub-models, building if needed."""
97+
if args.onnx_dir is not None:
98+
d = Path(args.onnx_dir)
99+
return {n: d / f"{n}.onnx" for n in ("embedding", "decoder", "audio_decoder")}
100+
if args.save_to is None:
101+
print(
102+
"[personaplex] Either --onnx-dir or --save-to must be provided.",
103+
file=sys.stderr,
104+
)
105+
sys.exit(2)
106+
return build_and_save(args.model, Path(args.save_to), args.dtype)
107+
108+
109+
# --------------------------------------------------------------------------- #
110+
# Inference loop
111+
# --------------------------------------------------------------------------- #
112+
113+
114+
def run_inference(
115+
paths: dict[str, Path], audio_codes_per_frame: list[np.ndarray], max_steps: int
116+
) -> list[tuple[int, np.ndarray]]:
117+
"""Process ``audio_codes_per_frame`` through the PersonaPlex pipeline.
118+
119+
Returns a list of ``(text_token, output_codes)`` per step.
120+
"""
121+
# Import lazily so the script imports without ORT installed
122+
sys.path.insert(0, str(Path(__file__).parent))
123+
from moshi_realtime import MoshiOnnxPipeline # type: ignore[import-not-found]
124+
125+
pipeline = MoshiOnnxPipeline(
126+
embedding_path=str(paths["embedding"]),
127+
decoder_path=str(paths["decoder"]),
128+
audio_decoder_path=str(paths["audio_decoder"]),
129+
)
130+
print(
131+
f"[personaplex] ORT sessions ready "
132+
f"(decoder KV cache: {len(pipeline._decoder_kv)} layer pairs, "
133+
f"depformer KV cache: {len(pipeline._depformer_kv)} layer pairs)"
134+
)
135+
136+
last_text = 0 # start token
137+
history: list[tuple[int, np.ndarray]] = []
138+
for step, audio_codes in enumerate(audio_codes_per_frame[:max_steps]):
139+
text_token, out_codes = pipeline.step(last_text, audio_codes)
140+
history.append((int(text_token), out_codes))
141+
last_text = int(text_token)
142+
return history
143+
144+
145+
# --------------------------------------------------------------------------- #
146+
# Audio I/O helpers
147+
# --------------------------------------------------------------------------- #
148+
149+
150+
def encode_audio_file(
151+
audio_path: Path, max_frames: int
152+
) -> tuple[list[np.ndarray], np.ndarray]:
153+
"""Load + resample to 24 kHz + Mimi-encode an audio file.
154+
155+
Returns:
156+
codes_per_frame: list of ``(NUM_CODEBOOKS,) int64`` arrays.
157+
raw_pcm: the resampled float32 PCM (for diagnostic RMS reporting).
158+
"""
159+
import librosa # type: ignore[import-not-found]
160+
161+
pcm, sr = librosa.load(str(audio_path), sr=SAMPLE_RATE, mono=True)
162+
pcm = pcm.astype(np.float32)
163+
n_frames = min(max_frames, len(pcm) // FRAME_SAMPLES)
164+
print(f"[personaplex] Loaded {audio_path.name}: {len(pcm) / sr:.2f}s, {n_frames} frames")
165+
166+
# Mimi is optional. If unavailable, synthesize all-zero codes.
167+
try:
168+
import torch # type: ignore[import-not-found]
169+
from moshi.models import loaders # type: ignore[import-not-found]
170+
171+
mimi = loaders.get_mimi(loaders.MIMI_NAME, device="cpu")
172+
mimi.set_num_codebooks(NUM_CODEBOOKS)
173+
codes_per_frame: list[np.ndarray] = []
174+
for i in range(n_frames):
175+
chunk = pcm[i * FRAME_SAMPLES : (i + 1) * FRAME_SAMPLES]
176+
with torch.no_grad():
177+
t = torch.from_numpy(chunk).reshape(1, 1, -1)
178+
codes = mimi.encode(t) # (1, num_codebooks, 1)
179+
codes_per_frame.append(codes[0, :, 0].cpu().numpy().astype(np.int64))
180+
print(f"[personaplex] Mimi-encoded {len(codes_per_frame)} frames")
181+
return codes_per_frame, pcm
182+
except (ImportError, OSError) as exc:
183+
print(
184+
f"[personaplex] Mimi unavailable ({exc.__class__.__name__}); "
185+
"falling back to all-zero audio codes. The pipeline still runs "
186+
"and outputs are exercised, but text/audio quality reflects "
187+
"silence-input, not your actual audio."
188+
)
189+
codes_per_frame = [np.zeros(NUM_CODEBOOKS, dtype=np.int64) for _ in range(n_frames)]
190+
return codes_per_frame, pcm
191+
192+
193+
def synthetic_codes(n_frames: int) -> list[np.ndarray]:
194+
"""Generate deterministic non-zero audio codes for sanity testing."""
195+
rng = np.random.default_rng(0)
196+
return [
197+
rng.integers(0, 2048, size=NUM_CODEBOOKS).astype(np.int64) for _ in range(n_frames)
198+
]
199+
200+
201+
# --------------------------------------------------------------------------- #
202+
# CLI
203+
# --------------------------------------------------------------------------- #
204+
205+
206+
def main() -> None:
207+
parser = argparse.ArgumentParser(
208+
description="Run PersonaPlex on an audio file via ONNX (file-based, no microphone)."
209+
)
210+
parser.add_argument(
211+
"--model",
212+
default="nvidia/personaplex-7b-v1",
213+
help="HuggingFace model id (default: nvidia/personaplex-7b-v1).",
214+
)
215+
parser.add_argument(
216+
"--audio",
217+
type=Path,
218+
default=None,
219+
help="Audio file to process (any sample rate; resampled to 24 kHz).",
220+
)
221+
parser.add_argument(
222+
"--synthetic",
223+
action="store_true",
224+
help="Skip audio file and run on synthetic non-zero codes (no Mimi required).",
225+
)
226+
parser.add_argument(
227+
"--save-to", type=str, default=None, help="Directory to export ONNX into."
228+
)
229+
parser.add_argument(
230+
"--onnx-dir",
231+
type=str,
232+
default=None,
233+
help="Directory of pre-exported ONNX sub-models (skips build step).",
234+
)
235+
parser.add_argument(
236+
"--dtype", default="float32", help="Build dtype: float32 (default) or bf16."
237+
)
238+
parser.add_argument(
239+
"--steps", type=int, default=8, help="Number of 80 ms frames to process."
240+
)
241+
242+
args = parser.parse_args()
243+
244+
paths = resolve_onnx_paths(args)
245+
246+
# ------------------------------------------------------------------ #
247+
# Prepare input audio codes
248+
# ------------------------------------------------------------------ #
249+
if args.synthetic:
250+
codes = synthetic_codes(args.steps)
251+
elif args.audio is not None:
252+
codes, _pcm = encode_audio_file(args.audio, args.steps)
253+
if not codes:
254+
print(
255+
"[personaplex] No frames decoded from audio file; is it shorter than 80 ms?",
256+
file=sys.stderr,
257+
)
258+
sys.exit(1)
259+
else:
260+
# Default to the repo's standard audio fixture if it exists; else synthetic.
261+
fixture = Path("testdata/652-129742-0006.flac")
262+
if fixture.exists():
263+
print(f"[personaplex] No --audio specified; using fixture {fixture}")
264+
codes, _pcm = encode_audio_file(fixture, args.steps)
265+
else:
266+
print(
267+
"[personaplex] No --audio specified and no fixture found; using synthetic codes"
268+
)
269+
codes = synthetic_codes(args.steps)
270+
271+
# ------------------------------------------------------------------ #
272+
# Run inference
273+
# ------------------------------------------------------------------ #
274+
history = run_inference(paths, codes, max_steps=args.steps)
275+
276+
print()
277+
print(f"[personaplex] {len(history)} steps processed:")
278+
for i, (text_tok, out_codes) in enumerate(history):
279+
print(f" step {i:2d}: text_token={text_tok:6d} out_codes={out_codes.tolist()}")
280+
281+
# ------------------------------------------------------------------ #
282+
# Decode text tokens (best-effort — tokenizer may be gated)
283+
# ------------------------------------------------------------------ #
284+
try:
285+
from transformers import AutoTokenizer # type: ignore[import-not-found]
286+
287+
tok = AutoTokenizer.from_pretrained(args.model, trust_remote_code=True)
288+
ids = [t for t, _ in history]
289+
text = tok.decode(ids, skip_special_tokens=False)
290+
print()
291+
print(f"[personaplex] decoded text: {text!r}")
292+
except Exception as exc:
293+
print(f"[personaplex] (skipping text decode: {exc.__class__.__name__})")
294+
295+
296+
if __name__ == "__main__":
297+
main()

0 commit comments

Comments
 (0)