[Feature] PersonaPlex (Moshi-based full-duplex S2S): native vLLM port + duplex serving (#4771)

Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
Signed-off-by: Sy0307 <24841857+Sy0307@users.noreply.github.com>
Co-authored-by: Sy0307 <24841857+Sy0307@users.noreply.github.com>
This commit is contained in:
Yueqian LinandSy0307 authored and GitHub committed 2026-08-04 20:10:33 -07:00
1 parent f24a6165ff
commit e788ef6e76
59 files changed
+10389 -12

No files matched your search

@@ -0,0 +1,77 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Offline PersonaPlex inference through the vllm-omni fullduplex backend.
This is the runnable end-to-end proof for the integration: it drives a user WAV
through :class:`PersonaPlexEngine` + :class:`PersonaPlexSession` (our wrappers
around the Moshi/Mimi lockstep loop) and writes the agent's audio + inner-monologue
text. It mirrors ``python -m moshi.offline`` but goes through our ``FrameStepper``
seam, so a green run validates that the session/adapter plumbing is faithful.
Prereqs: HF access to ``nvidia/personaplex-7b-v1`` (``export HF_TOKEN=...``). The
stack is moshi-free (native temporal + depformer + streaming Mimi via
``transformers``); decoding is greedy.
Example:
python examples/offline_inference/personaplex/personaplex_offline.py \\
--input-wav input_assistant.wav --output-wav out.wav \\
--voice-prompt NATF2.pt --persona "You are a wise and friendly teacher."
"""
from __future__ import annotations
import argparse
import json
import numpy as np
from vllm_omni.experimental.fullduplex.personaplex import (
PersonaPlexConfig,
PersonaPlexEngine,
PersonaPlexSession,
)
from vllm_omni.experimental.fullduplex.personaplex.config import DEFAULT_PERSONA
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input-wav", required=True, help="User-side input WAV")
parser.add_argument("--output-wav", required=True, help="Agent audio output WAV")
parser.add_argument("--output-text", default=None, help="Optional JSON for agent text")
parser.add_argument("--voice-prompt", default="NATF2.pt", help="Voice clone (bundled .pt or .wav)")
parser.add_argument("--persona", default=DEFAULT_PERSONA, help="System role text")
parser.add_argument("--hf-repo", default="nvidia/personaplex-7b-v1")
parser.add_argument("--device", default="cuda")
parser.add_argument("--cpu-offload", action="store_true")
args = parser.parse_args()
import sphn # local import: only the runnable driver needs the audio I/O dep
config = PersonaPlexConfig(
hf_repo=args.hf_repo,
voice_prompt=args.voice_prompt,
persona=args.persona,
device=args.device,
cpu_offload=args.cpu_offload,
)
engine = PersonaPlexEngine(config).load()
session = PersonaPlexSession(engine, config)
session.open()
# Load exactly like the reference (moshi.lm.load_audio): native read then
# explicit resample, so greedy runs stay bit-faithful to `python -m moshi.offline`.
user_pcm, src_sr = sphn.read(args.input_wav)
user_pcm = sphn.resample(user_pcm, src_sample_rate=src_sr, dst_sample_rate=config.sample_rate)
audio, text = session.run(np.asarray(user_pcm[0], dtype=np.float32))
sphn.write_wav(args.output_wav, audio, config.sample_rate)
print(f"wrote {audio.shape[0] / config.sample_rate:.2f}s of agent audio to {args.output_wav}")
print(f"agent text: {text!r}")
if args.output_text:
with open(args.output_text, "w") as f:
json.dump({"text": text}, f, ensure_ascii=False)
if __name__ == "__main__":
main()
@@ -0,0 +1,84 @@
# PersonaPlex full-duplex online serving
Serve [`nvidia/personaplex-7b-v1`](https://huggingface.co/nvidia/personaplex-7b-v1)
(a Moshi-based full-duplex speech-to-speech model) with the native vLLM-Omni engine.
The server hosts the **official PersonaPlex web client** at `/` (auto-downloaded from
the model repo) and implements its WebSocket protocol at `/api/chat`, so you talk to the
model live in the browser — mic in, agent speech + inner-monologue text out.
> Experimental. Requires a GPU and Hugging Face access to the gated repo
> (`HF_TOKEN` with access to `nvidia/personaplex-7b-v1`).
## Start the server
```bash
HF_TOKEN=... CUDA_VISIBLE_DEVICES=0 bash run_server.sh
# or directly:
HF_TOKEN=... CUDA_VISIBLE_DEVICES=0 python -m \
vllm_omni.experimental.fullduplex.personaplex.serving.server --port 8124
```
## Talk to it
- **Browser (recommended):** open `http://localhost:8124/` and allow the microphone.
Use headphones so the agent does not hear itself.
- **Headless:** stream a 24 kHz mono WAV and save the reply:
```bash
python duplex_client.py --url ws://localhost:8124/api/chat --input user.wav --out reply.wav
```
## Unified Realtime API
The default `vllm_omni/deploy/personaplex.yaml` also enables main's engine-owned
full-duplex control plane:
```bash
CUDA_VISIBLE_DEVICES=0 python -m vllm_omni.entrypoints.cli.main serve \
/path/to/personaplex-7b-v1 \
--omni \
--stage-configs-path vllm_omni/deploy/personaplex.yaml
```
Validate the actual `/v1/realtime?duplex=1` scheduler path with paced 24 kHz
PCM, two concurrent sessions, overflow admission, per-session slot recycling,
and non-silent output:
```bash
python tests/e2e/online_serving/personaplex_realtime_duplex.py \
--model /path/to/personaplex-7b-v1 \
--input-wav /path/to/speech.wav \
--output-dir /tmp/personaplex-realtime-duplex
```
This is distinct from the standalone `/api/chat` compatibility server above.
The unified endpoint advertises `supports_barge_in=false`: overlapping speech
is native model behavior, but destructive output interruption and model-state
rewind have not been validated for PersonaPlex.
## Protocol (`/api/chat`)
Binary WebSocket messages, first byte is a tag (identical to `moshi.server`):
| Direction | Tag | Payload |
| --- | --- | --- |
| server → client | `\x00` | ready handshake (after the system-prompt prefill) |
| client → server | `\x01` | Opus-encoded mic audio (24 kHz) |
| server → client | `\x01` | Opus-encoded agent audio |
| server → client | `\x02` | UTF-8 inner-monologue text |
Connect with query params `text_prompt` (persona) and `voice_prompt` (voice file).
A raw-PCM endpoint (`/v1/audio/duplex`, JSON `open` + float32 frames) is also available
for clients that do not want an Opus dependency.
## Notes
- **Run the client near the server.** Real-time 80 ms audio is sensitive to network
latency/jitter; over a high-latency remote link playback can stutter regardless of
engine speed. On localhost it is smooth.
- Greedy decoding by default. One conversation per server by default (a second
concurrent connection is rejected with WS close 1013 until the first ends);
pass `--batch-size N` to host N concurrent conversations on one engine
(elastic batching; connections beyond N are rejected until a slot frees).
- Full runbook: [`recipes/NVIDIA/PersonaPlex.md`](../../../recipes/NVIDIA/PersonaPlex.md).
@@ -0,0 +1,114 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Headless client for the PersonaPlex duplex server (official Moshi protocol).
Streams a 24 kHz mono WAV to ``/api/chat`` exactly as the browser client does
(Opus over WebSocket, tags ``\\x00`` ready / ``\\x01`` audio / ``\\x02`` text),
collects the agent's reply audio + inner-monologue text, and writes a WAV.
The browser UI at ``http://<host>:<port>/`` is the primary demo; this script is
for headless testing / scripting.
python duplex_client.py --url ws://localhost:8124/api/chat \
--input user.wav --out reply.wav
Deps: ``websockets``, ``sphn``, ``soundfile``, ``numpy``.
"""
from __future__ import annotations
import argparse
import asyncio
import numpy as np
import soundfile as sf
import sphn
SR = 24000 # PersonaPlex / Mimi codec rate
async def converse(url: str, pcm: np.ndarray) -> tuple[np.ndarray, str]:
import websockets
reader = sphn.OpusStreamReader(SR) # decode agent audio
writer = sphn.OpusStreamWriter(SR) # encode our mic audio
text: list[str] = []
ready = asyncio.Event()
last = {"t": 0.0}
async with websockets.connect(url, max_size=None) as ws:
async def receive() -> None:
loop = asyncio.get_event_loop()
while True:
try:
msg = await ws.recv()
except Exception:
return
if not isinstance(msg, (bytes, bytearray)) or not msg:
continue
tag, payload = msg[0], msg[1:]
if tag == 0:
ready.set()
elif tag == 1:
reader.append_bytes(bytes(payload))
last["t"] = loop.time()
elif tag == 2:
text.append(payload.decode("utf8", errors="replace"))
recv_task = asyncio.create_task(receive())
await asyncio.wait_for(ready.wait(), timeout=120) # waits for system-prompt prefill
frame = 1920 # 80 ms
for i in range(0, len(pcm), frame):
chunk = pcm[i : i + frame]
if len(chunk) < frame:
chunk = np.concatenate([chunk, np.zeros(frame - len(chunk), dtype=np.float32)])
writer.append_pcm(chunk)
data = writer.read_bytes()
if data:
await ws.send(b"\x01" + data)
await asyncio.sleep(frame / SR) # pace at real time
loop = asyncio.get_event_loop()
last["t"] = loop.time()
while loop.time() - last["t"] < 2.5: # drain until the agent stops talking
await asyncio.sleep(0.1)
recv_task.cancel()
chunks: list[np.ndarray] = []
while True:
out = reader.read_pcm()
if out is None or out.shape[-1] == 0:
break
chunks.append(np.asarray(out, dtype=np.float32))
audio = np.concatenate(chunks) if chunks else np.zeros(0, dtype=np.float32)
return audio, "".join(text)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--url", default="ws://localhost:8124/api/chat")
ap.add_argument("--input", required=True, help="24 kHz mono WAV of the user turn")
ap.add_argument("--out", default="personaplex_reply.wav")
ap.add_argument("--tail", type=float, default=10.0, help="trailing silence (s) for the agent reply")
ap.add_argument("--persona", default="")
ap.add_argument("--voice", default="NATF2.pt")
args = ap.parse_args()
wav, sr = sf.read(args.input, dtype="float32")
if wav.ndim > 1:
wav = wav.mean(axis=1)
if sr != SR:
raise SystemExit(f"input must be {SR} Hz mono; got {sr} Hz. Resample it first.")
wav = np.concatenate([wav, np.zeros(int(args.tail * SR), dtype=np.float32)])
url = f"{args.url}?text_prompt={args.persona}&voice_prompt={args.voice}"
audio, text = asyncio.run(converse(url, wav))
sf.write(args.out, audio if audio.size else np.zeros(SR, dtype=np.float32), SR)
print(f"agent reply: {audio.size / SR:.2f}s -> {args.out}")
print(f"inner-monologue: {text.strip()!r}")
if __name__ == "__main__":
main()
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# PersonaPlex full-duplex online serving.
#
# Serves the OFFICIAL PersonaPlex web client at / (auto-downloaded from
# nvidia/personaplex-7b-v1) and the Moshi WebSocket protocol at /api/chat,
# backed by the native vLLM-Omni engine. Requires HF access to the gated repo.
HOST="${HOST:-0.0.0.0}"
PORT="${PORT:-8124}"
echo "Starting PersonaPlex duplex server on ${HOST}:${PORT}..."
echo "Open http://localhost:${PORT}/ for the official web client (run it near the server)."
python -m vllm_omni.experimental.fullduplex.personaplex.serving.server \
--host "$HOST" --port "$PORT"
+174
View File
@@ -0,0 +1,174 @@
# PersonaPlex
> Live full-duplex speech-to-speech (talk to the model in your browser, it listens
> while it speaks) with persona and voice control, on the native vLLM-Omni engine.
## Summary
- Vendor: NVIDIA
- Model: [`nvidia/personaplex-7b-v1`](https://huggingface.co/nvidia/personaplex-7b-v1)
(gated; a Moshi finetune)
- Task: Full-duplex speech-to-speech. 24 kHz mic audio in, agent speech +
inner-monologue text out, in 80 ms lockstep. Persona (role text) and voice
(zero-shot clone) are set per session.
- Mode: Live duplex WebSocket server with the official browser client, single or
multi-session (`--batch-size`), plus an offline WAV-in / WAV-out example
- Maintainer: [`@linyueqian`](https://github.com/linyueqian)
## When to use this recipe
Use this recipe to run `nvidia/personaplex-7b-v1` as a live voice agent on one GPU.
The integration is **moshi-free**: the temporal transformer, depformer, and
streaming Mimi codec are all native vLLM-Omni modules (Mimi runs on
`transformers.MimiModel`), so no vendored fork is installed. Decoding is greedy,
matching the reference implementation frame for frame on the golden replays used
as the acceptance gate.
PersonaPlex is the first Moshi-class (pure-lockstep) model in the experimental
full-duplex framework (`vllm_omni/experimental/fullduplex/`), and the first with
elastic multi-session batching: one engine hosts `--batch-size` concurrent
conversations, callers join or leave on any 80 ms tick without disturbing live
slots (greedy isolation is bit-exact).
## References
- Serving package:
[`vllm_omni/experimental/fullduplex/personaplex/`](../../vllm_omni/experimental/fullduplex/personaplex/)
- Native model modules (temporal / depformer / streaming Mimi):
[`vllm_omni/model_executor/models/personaplex/`](../../vllm_omni/model_executor/models/personaplex/)
- Online example (server + headless client):
[`examples/online_serving/personaplex/`](../../examples/online_serving/personaplex/)
- Offline example:
[`examples/offline_inference/personaplex/personaplex_offline.py`](../../examples/offline_inference/personaplex/personaplex_offline.py)
- Staged offline pipeline (talker -> Mimi code2wav, async-chunk streaming):
[`vllm_omni/deploy/personaplex.yaml`](../../vllm_omni/deploy/personaplex.yaml)
- Integration PR: [vllm-project/vllm-omni#4771](https://github.com/vllm-project/vllm-omni/pull/4771)
- Framework context: [RFC #3745](https://github.com/vllm-project/vllm-omni/issues/3745)
(duplex adapter patterns), [#1335](https://github.com/vllm-project/vllm-omni/issues/1335)
(full-duplex target), [PR #3907](https://github.com/vllm-project/vllm-omni/pull/3907)
(fullduplex core contracts this adapter conforms to)
- Upstream model card:
[`nvidia/personaplex-7b-v1`](https://huggingface.co/nvidia/personaplex-7b-v1)
## Why it is different from MiniCPM-o / JoyVL duplex
| | MiniCPM-o 4.5 (#3907) | JoyVL | **PersonaPlex (this)** |
|---|---|---|---|
| Cadence | 1 s chunk groups | ~1 fps frames | **80 ms lockstep (12.5 Hz)** |
| Turn control | learned `⟨listen⟩`/`⟨speak⟩` | `</silence>`/`</response>` | **none, pure lockstep** |
| Per step | variable-length token group | text decision | **1 user frame in -> 1 agent frame + 1 text token** |
| Barge-in | at chunk boundary | n/a | **native (model always hears the user)** |
| Session state | chunk-group KV | per-tick HTTP | **persistent ring KV + streaming Mimi state** |
This is the lockstep ("parallel-frame joint") shape of the duplex adapter
patterns: the adapter declares `DuplexCapability.continuous = True`, and
`core.DuplexRuntime` runs ONE eternal response that consumes input frames as they
arrive and drains on close. The flag is a small, default-off lifecycle mode, so
turn-based adapters (JoyVL, MiniCPM-o) are unaffected.
## Architecture (Moshi RQ-Transformer, all native)
- **Mimi codec** 24 kHz, 12.5 Hz, 1920 samples/frame, 8 active codebooks
(card 2048). Streaming encode + decode via `transformers.MimiModel` with the
PersonaPlex reference checkpoint weights.
- **Temporal transformer** (Helium backbone) 4096-d / 32 layers / 32 heads,
sliding window 3000 frames (a 4-minute rolling context) held in a ring KV cache
with per-slot recycle.
- **Depformer** 1024-d / 6 layers / 16 heads, autoregressive over 16 codebooks
per frame (8 vocoded), reset each frame.
- Per-frame token column `[B, 17, 1]`: row 0 = inner-monologue text,
rows 1-8 = agent audio, rows 9-16 = user audio.
- Persona + voice are injected once at session open through the same lockstep
step: voice clone forces the agent stream from reference-audio Mimi codes;
persona forces the text stream from `<system> ... <system>` tokens.
## Hardware Support
Verified on GPU (Hopper-class). The serving path is plain PyTorch eager plus the
native modules, so other CUDA GPUs with enough memory are expected to work.
## GPU
### 1x 141 GB Hopper-class GPU (H20-class, verified)
#### Environment
- OS: Linux
- Python: 3.10+ (CI covers 3.11 / 3.12)
- vLLM-Omni: PR #4771 branch or current `main` once merged
- Extra dependency for the live server: `pip install sphn` (Opus framing;
`aiohttp` already ships with vLLM)
- `export HF_TOKEN=...` with access to the gated
`nvidia/personaplex-7b-v1` repo (accept the license on the model page)
First run auto-downloads from the model repo: `model.safetensors`,
`tokenizer_spm_32k_3.model`, `voices.tgz`, and the Mimi reference checkpoint;
`kyutai/mimi` is fetched via `transformers` for the codec module graph.
#### Command
Live duplex server (official browser client served at `/`):
```bash
HF_TOKEN=... CUDA_VISIBLE_DEVICES=0 \
python -m vllm_omni.experimental.fullduplex.personaplex.serving.server \
--port 8124 --voice NATF2.pt
```
Multi-session on the same GPU (elastic batching, callers join/leave any tick):
```bash
HF_TOKEN=... CUDA_VISIBLE_DEVICES=0 \
python -m vllm_omni.experimental.fullduplex.personaplex.serving.server \
--port 8124 --batch-size 4
```
Then open `http://localhost:8124/` and allow the microphone (use headphones so
the agent does not hear itself). Headless alternative:
```bash
python examples/online_serving/personaplex/duplex_client.py \
--url ws://localhost:8124/api/chat --input user.wav --out reply.wav
```
Offline WAV-in / WAV-out (no server):
```bash
python examples/offline_inference/personaplex/personaplex_offline.py \
--input-wav question.wav --output-wav reply.wav --output-text reply.json \
--voice-prompt NATF2.pt --persona "You are a wise and friendly teacher."
```
#### Verification
```bash
# GPU-free contract tests (stubbed FrameStepper)
pytest tests/e2e/features/fullduplex/test_personaplex_adapter.py \
tests/e2e/features/fullduplex/test_personaplex_session.py \
tests/e2e/features/fullduplex/test_personaplex_batched.py -q
# GPU e2e: batched isolation + slot recycle (coherence check needs a real
# question WAV via PPLEX_QUESTION_WAV)
pytest tests/e2e/features/fullduplex/test_personaplex_elastic.py -q
```
A green offline run producing a coherent spoken reply (and inner-monologue text
in `reply.json`) is the quickest end-to-end check.
#### Notes
- Realtime budget is 80 ms/frame. Verified eager per-tick latency at
`--batch-size 4` is ~70-74 ms on this hardware class, i.e. all four
conversations stay realtime; single-session has comfortable headroom.
- Decoding is greedy only (temperature/top-k knobs are intentionally not
exposed): greedy is what the parity gates pin against the reference
implementation, and sampling amplifies sub-bit numeric drift into divergence.
- With `--batch-size N`, connections beyond N are rejected until a slot frees;
a recycled slot is bit-exact with a fresh engine for the same inputs.
- Voice prompts: bundled `voices.tgz` names (`NATF*`/`NATM*` natural,
`VARF*`/`VARM*` varied) or a path to your own `.pt`/`.wav`.
- Run the browser client near the server. The 80 ms cadence is sensitive to
network jitter; over a high-latency link playback can stutter regardless of
engine speed. On localhost it is smooth.
- Sliding window is 3000 frames (~4 min). The ring KV recycles beyond that;
very long sessions keep running with a rolling context.
+17
View File
@@ -81,6 +81,21 @@ def _from_pipeline_key(
)
def test_duplex_session_capacity_propagates_to_every_structured_stage() -> None:
omni_config = _from_pipeline_key("personaplex")
assert [stage.model_config.duplex_max_sessions for stage in omni_config.stage_configs] == [2, 2]
def test_non_duplex_deploy_keeps_model_session_capacity_at_one(tmp_path: Path) -> None:
deploy_path = tmp_path / "personaplex-turn.yaml"
deploy_path.write_text("session_mode: turn\n", encoding="utf-8")
omni_config = _from_pipeline_key("personaplex", deploy_config_path=str(deploy_path))
assert [stage.model_config.duplex_max_sessions for stage in omni_config.stage_configs] == [1, 1]
@pytest.mark.parametrize("model_type", sorted(OMNI_PIPELINES))
def test_vllm_omni_config_from_pipeline_config_matches_merge_pipeline_deploy(model_type: str):
pipeline = _resolve_pipeline_or_skip(model_type)
@@ -105,6 +120,7 @@ def test_vllm_omni_config_from_pipeline_config_matches_merge_pipeline_deploy(mod
assert omni_stage.runtime_config.num_replicas == legacy_stage.yaml_runtime.get("num_replicas", 1)
engine_args = legacy_stage.yaml_engine_args
assert omni_stage.model_config.duplex_max_sessions == engine_args.get("duplex_max_sessions", 1)
assert omni_stage.model_config.enforce_eager == engine_args.get("enforce_eager", False)
assert omni_stage.load_config.load_format == engine_args.get("load_format", "auto")
assert omni_stage.load_config.tokenizer_mode == engine_args.get("tokenizer_mode", "auto")
@@ -379,6 +395,7 @@ def test_runtime_config_fields_match_rfc_runtime_scope():
def test_sub_config_fields_match_rfc_scopes():
assert {f.name for f in fields(OmniStageModelConfig)} == {
"active_stream_window",
"duplex_max_sessions",
"enable_sleep_mode",
"default_sampling_params",
"subtalker_sampling_params",
@@ -110,6 +110,9 @@ def test_resumable_generation_stop_marks_segment_boundary() -> None:
session.status = RequestStatus.RUNNING
session.resumable = True
session.num_computed_tokens = len(session.prompt_token_ids)
# schedule() has counted this step's token as in flight; update_from_output()
# must settle it before handling the segment boundary.
session.num_in_flight_tokens = 1
sched = MagicMock()
sched.requests = {session.request_id: session}
@@ -118,12 +121,20 @@ def test_resumable_generation_stop_marks_segment_boundary() -> None:
is_done_receiving_chunks=lambda _request_id: True,
segment_finished_requests={session.request_id},
)
sched._handle_stopped_request.return_value = False
def rearm_resumable_request(request: Request) -> bool:
request.status = RequestStatus.WAITING
return False
sched._handle_stopped_request.side_effect = rearm_resumable_request
sched.running = [session]
sched.waiting = MagicMock()
sched.skipped_waiting = MagicMock()
sched.structured_output_manager.should_advance.return_value = False
sched._pending_finish_reqs = []
# Async scheduling can observe the segment boundary in the next schedule()
# before this model output is applied and defer the same request for finish.
# update_from_output() must not re-arm the resumable request twice.
sched._pending_finish_reqs = [session]
sched.recompute_kv_load_failures = False
sched.connector = None
sched.kv_cache_manager.take_events.return_value = None
@@ -155,6 +166,8 @@ def test_resumable_generation_stop_marks_segment_boundary() -> None:
output = outputs[session.client_index].outputs[0]
assert output.finish_reason is not None
assert output.is_segment_finished is True
sched._handle_stopped_request.assert_called_once_with(session)
assert sched._pending_finish_reqs == []
def test_async_chunk_resumable_stop_rearms_connector_polling() -> None:
@@ -390,6 +390,105 @@ def test_send_single_request_respects_processor_receiver_boundary(build_adapter,
assert sent_payload.meta.is_segment_finished.item() is False
def test_send_single_request_personaplex_pending_frame_is_not_segment_boundary(
build_adapter,
):
from vllm_omni.model_executor.stage_input_processors.personaplex import (
talker2code2wav_async_chunk,
)
adapter, connector = build_adapter(
stage_id=0,
connector_extra={
"initial_codec_chunk_frames": 1,
"codec_chunk_frames": 5,
},
)
request = _req(
"req-personaplex",
RequestStatus.WAITING,
external_req_id="ext-personaplex",
)
request.resumable = True
request.additional_information = {
"codes": {
"audio": torch.arange(8, dtype=torch.long).reshape(1, 8),
}
}
adapter.custom_process_next_stage_input_func = talker2code2wav_async_chunk
adapter._send_single_request(
{
"multimodal_output": None,
"request": request,
"is_finished": False,
"is_segment_finished": True,
}
)
sent_payload = connector.put.call_args.kwargs["data"]
assert sent_payload.codes is None
assert sent_payload.meta.finished.item() is False
assert sent_payload.meta.is_segment_finished.item() is False
def test_personaplex_sender_cleanup_drops_delayed_frame_state(build_adapter):
from vllm_omni.model_executor.stage_input_processors.personaplex import (
talker2code2wav_async_chunk,
)
adapter, _ = build_adapter(
stage_id=0,
connector_extra={
"initial_codec_chunk_frames": 1,
"codec_chunk_frames": 5,
},
)
request = _req(
"req-personaplex-first",
RequestStatus.WAITING,
external_req_id="ext-personaplex-reused",
)
request.resumable = True
request.additional_information = {
"codes": {
"audio": torch.arange(8, dtype=torch.long).reshape(1, 8),
}
}
first = talker2code2wav_async_chunk(
adapter,
multimodal_output=None,
request=request,
is_finished=True,
)
assert first is not None
assert first.codes is None
adapter.cleanup_sender(request.external_req_id)
replacement = _req(
"req-personaplex-replacement",
RequestStatus.WAITING,
external_req_id=request.external_req_id,
)
replacement.resumable = True
replacement.additional_information = {
"codes": {
"audio": torch.arange(8, 16, dtype=torch.long).reshape(1, 8),
}
}
second = talker2code2wav_async_chunk(
adapter,
multimodal_output=None,
request=replacement,
is_finished=True,
)
assert second is not None
assert second.codes is None
def test_save_async_skips_stale_resumable_chunk_until_dedup_is_reset(build_adapter):
adapter, _ = build_adapter(stage_id=1)
request = _req("req-stream", RequestStatus.WAITING, external_req_id="ext-stream")
@@ -0,0 +1,30 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import subprocess
import sys
def test_batched_runtime_import_does_not_require_optional_opus_dependency() -> None:
script = """
import builtins
real_import = builtins.__import__
def guarded_import(name, *args, **kwargs):
if name == "sphn":
raise ModuleNotFoundError("blocked optional dependency: sphn")
return real_import(name, *args, **kwargs)
builtins.__import__ = guarded_import
from vllm_omni.experimental.fullduplex.personaplex.serving.batched import BatchedSessionManager
assert BatchedSessionManager is not None
"""
result = subprocess.run(
[sys.executable, "-c", script],
check=False,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
@@ -0,0 +1,303 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import base64
from types import SimpleNamespace
import numpy as np
import pytest
import torch
from vllm_omni.experimental.fullduplex.personaplex.policy import (
SILENCE_TOKENS,
SINE_TOKENS,
)
from vllm_omni.experimental.fullduplex.personaplex.stage0 import (
PersonaPlexStage0DuplexRuntime,
)
from vllm_omni.model_executor.models.personaplex.personaplex_talker import (
PersonaPlexTalkerForConditionalGeneration,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _FakeCodec:
def __init__(self) -> None:
self.encode_calls = 0
self.reset_calls = 0
def streaming_init(self, batch_size: int) -> None:
assert batch_size == 1
def encode_frame(self, pcm: torch.Tensor) -> torch.Tensor:
assert pcm.shape == (1, 1920)
self.encode_calls += 1
return torch.full((1, 8), self.encode_calls, dtype=torch.long)
def reset_streaming(self) -> None:
self.reset_calls += 1
class _FakeTalker:
def __init__(self) -> None:
self.device = torch.device("cpu")
self.dtype = torch.float32
self.frame_calls: list[dict[str, torch.Tensor | None]] = []
def _build_prefill_embed(
self,
tokens,
offset,
span,
device,
silence=None,
user_sine=None,
):
del offset, silence, user_sine
values = tokens[:span].to(device=device, dtype=torch.float32)
return values[:, None].expand(-1, 4).contiguous()
def _build_frame_embed(
self,
text_token,
last_agent,
prev_agent,
device,
user_d0=None,
user_d1=None,
):
self.frame_calls.append(
{
"text_token": text_token.clone(),
"last_agent": None if last_agent is None else last_agent.clone(),
"prev_agent": None if prev_agent is None else prev_agent.clone(),
"user_d0": None if user_d0 is None else user_d0.clone(),
"user_d1": None if user_d1 is None else user_d1.clone(),
}
)
value = float(user_d0[0].item()) if user_d0 is not None else -1.0
return torch.full((1, 4), value, device=device)
def _duplex_info(*, seq: int, session_id: str = "session", incarnation: int = 1):
pcm = np.zeros(1920, dtype="<f4")
return {
"data_plane": True,
"session_id": session_id,
"incarnation": incarnation,
"epoch": 0,
"seq": seq,
"payload": {
"format": "pcm_f32le",
"sample_rate_hz": 24000,
"audio": base64.b64encode(pcm.tobytes()).decode("ascii"),
},
"runtime_config": {
"personaplex_model_path": "/unused",
"personaplex_voice_prompt": "NATF2.pt",
"personaplex_persona": "Be concise.",
},
}
def _runtime(*codecs: _FakeCodec) -> PersonaPlexStage0DuplexRuntime:
voice_embeddings = torch.arange(8, dtype=torch.float32).reshape(2, 1, 1, 4)
codec_args = {"codec": codecs[0]}
if len(codecs) > 1:
available = iter(codecs)
codec_args = {
"codec_factory": lambda: next(available),
"max_sessions": len(codecs),
}
return PersonaPlexStage0DuplexRuntime(
_FakeTalker(),
model_path="/unused",
device="cpu",
**codec_args,
tokenizer=lambda _text: [7, 8, 9],
voice_loader=lambda _voice: {
"embeddings": voice_embeddings,
"cache": torch.zeros((1, 17, 4), dtype=torch.long),
},
)
def test_first_append_prepends_voice_and_persona_once() -> None:
codec = _FakeCodec()
runtime = _runtime(codec)
first = runtime.prepare_append(_duplex_info(seq=1), prompt_len=18)
second = runtime.prepare_append(_duplex_info(seq=2), prompt_len=18)
assert first.prefill_applied is True
assert first.prompt_offset == 0
assert first.user_codes.shape == (1, 8)
assert first.inputs_embeds.shape == (18, 4)
assert second.prefill_applied is False
assert second.prompt_offset == 17
assert second.user_codes.shape == (2, 8)
assert second.inputs_embeds.shape == (1, 4)
assert codec.encode_calls == 2
@pytest.mark.parametrize(
("append_count", "expected_tokens", "expected_provided"),
[
(
1,
[*SILENCE_TOKENS, 1, *SINE_TOKENS[1:]],
[False, *([True] * 15)],
),
(
2,
[*SILENCE_TOKENS, 2, *([1] * 7)],
[*([False] * 8), *([True] * 8)],
),
],
)
def test_prepare_append_preserves_native_depformer_teacher_forcing(
append_count: int,
expected_tokens: list[int],
expected_provided: list[bool],
) -> None:
runtime = _runtime(_FakeCodec())
result = None
for seq in range(1, append_count + 1):
result = runtime.prepare_append(_duplex_info(seq=seq), prompt_len=18)
assert result is not None
assert result.info_update["pplex_depformer_audio_tokens"].tolist() == expected_tokens
assert result.info_update["pplex_depformer_audio_provided"].tolist() == expected_provided
def test_frame_embed_uses_previous_effective_agent_frame() -> None:
fake_talker = SimpleNamespace(
config=SimpleNamespace(
num_audio_codebooks=16,
audio_vocab_size=2048,
),
input_embeddings=lambda stack: stack,
)
last_agent = torch.arange(10, 18)
embeds = PersonaPlexTalkerForConditionalGeneration._build_frame_embed(
fake_talker,
torch.tensor([3]),
last_agent,
torch.arange(8),
torch.device("cpu"),
)
assert embeds.shape == (1, 17)
assert torch.equal(embeds[0, 1:9], last_agent)
def test_repeated_append_identity_does_not_advance_codec() -> None:
codec = _FakeCodec()
runtime = _runtime(codec)
info = _duplex_info(seq=1)
first = runtime.prepare_append(info, prompt_len=18, request_id="req")
retry = runtime.prepare_append(info, prompt_len=18, request_id="req")
assert codec.encode_calls == 1
assert torch.equal(first.inputs_embeds, retry.inputs_embeds)
assert torch.equal(first.user_codes, retry.user_codes)
def test_next_append_uses_prior_sample_and_causally_delayed_user_frame() -> None:
runtime = _runtime(_FakeCodec())
runtime.prepare_append(_duplex_info(seq=1), prompt_len=18, request_id="req")
first_agent = torch.arange(8, dtype=torch.long)
runtime.record_sample(request_id="req", text_token=torch.tensor(101), agent_codes=first_agent)
runtime.prepare_append(_duplex_info(seq=2), prompt_len=19, request_id="req")
second_agent = torch.arange(10, 18, dtype=torch.long)
runtime.record_sample(request_id="req", text_token=torch.tensor(102), agent_codes=second_agent)
runtime.prepare_append(_duplex_info(seq=3), prompt_len=20, request_id="req")
silence = torch.tensor(SILENCE_TOKENS, dtype=torch.long)
sine = torch.tensor(SINE_TOKENS, dtype=torch.long)
first_call = runtime.stage_model.frame_calls[0]
assert all(torch.equal(first_call[key], silence) for key in ("last_agent", "prev_agent"))
assert all(torch.equal(first_call[key], sine) for key in ("user_d0", "user_d1"))
second_call = runtime.stage_model.frame_calls[1]
assert second_call["text_token"].tolist() == [101]
expected_first_effective = torch.cat([first_agent[:1], silence[1:]])
assert torch.equal(second_call["last_agent"], expected_first_effective)
assert torch.equal(second_call["prev_agent"], expected_first_effective)
assert torch.equal(second_call["user_d0"], torch.full((8,), 1, dtype=torch.long))
assert torch.equal(second_call["user_d1"], sine)
third_call = runtime.stage_model.frame_calls[2]
assert third_call["text_token"].tolist() == [102]
assert torch.equal(third_call["last_agent"], second_agent)
assert torch.equal(third_call["prev_agent"], second_agent)
assert torch.equal(third_call["user_d0"], torch.full((8,), 2, dtype=torch.long))
assert torch.equal(third_call["user_d1"], torch.full((8,), 1, dtype=torch.long))
def test_decoded_pcm_is_writable_for_torch_zero_copy() -> None:
pcm = PersonaPlexStage0DuplexRuntime._decode_pcm(_duplex_info(seq=1)["payload"])
assert pcm.flags.writeable
def _prepare_two_sessions(
runtime: PersonaPlexStage0DuplexRuntime,
) -> tuple[object, object, object, object]:
first_1 = runtime.prepare_append(_duplex_info(seq=1), prompt_len=18)
second_1 = runtime.prepare_append(
_duplex_info(seq=1, session_id="other", incarnation=2),
prompt_len=18,
)
first_2 = runtime.prepare_append(_duplex_info(seq=2), prompt_len=19)
second_2 = runtime.prepare_append(
_duplex_info(seq=2, session_id="other", incarnation=2),
prompt_len=19,
)
return first_1, second_1, first_2, second_2
def test_live_sessions_keep_independent_streaming_encoders() -> None:
first_codec = _FakeCodec()
second_codec = _FakeCodec()
runtime = _runtime(first_codec, second_codec)
first_1, second_1, first_2, second_2 = _prepare_two_sessions(runtime)
assert first_1.user_codes[:, 0].tolist() == [1]
assert second_1.user_codes[:, 0].tolist() == [1]
assert first_2.user_codes[:, 0].tolist() == [1, 2]
assert second_2.user_codes[:, 0].tolist() == [1, 2]
def test_stage0_session_capacity_fails_before_codec_state_is_shared() -> None:
runtime = _runtime(_FakeCodec(), _FakeCodec())
_prepare_two_sessions(runtime)
with pytest.raises(RuntimeError, match="capacity 2"):
runtime.prepare_append(
_duplex_info(seq=1, session_id="third", incarnation=3),
prompt_len=18,
)
def test_close_session_resets_and_reuses_released_codec() -> None:
first_codec = _FakeCodec()
second_codec = _FakeCodec()
runtime = _runtime(first_codec, second_codec)
_prepare_two_sessions(runtime)
runtime.close_session("session", 1)
assert first_codec.reset_calls == 1
assert second_codec.reset_calls == 0
replacement = runtime.prepare_append(
_duplex_info(seq=1, session_id="replacement", incarnation=4),
prompt_len=18,
)
assert replacement.user_codes[:, 0].tolist() == [3]
@@ -0,0 +1,316 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from types import SimpleNamespace
import pytest
import torch
from torch import nn
from vllm_omni.model_executor.models.personaplex import (
personaplex_code2wav,
personaplex_mimi,
)
from vllm_omni.model_executor.models.personaplex.personaplex_code2wav import (
PersonaPlexCode2Wav,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _FakeStreamingMimi(nn.Module):
def __init__(self, stream_value: int = 0) -> None:
super().__init__()
self.stream_value = stream_value
self.decode_frame_calls = 0
self.reset_calls = 0
def decode(self, codes: torch.Tensor, return_dict: bool = True):
assert return_dict
frames = int(codes.shape[-1])
return SimpleNamespace(audio_values=torch.arange(frames * 4, dtype=torch.float32).reshape(1, 1, -1))
def streaming_init(self, batch_size: int) -> None:
assert batch_size == 1
def decode_frame(self, codes: torch.Tensor) -> torch.Tensor:
assert codes.shape == (1, 2)
self.decode_frame_calls += 1
value = self.stream_value + self.decode_frame_calls
return torch.full((1, 4), float(value), dtype=torch.float32)
def reset_streaming(self) -> None:
self.reset_calls += 1
class _FakeChunkedMimi(_FakeStreamingMimi):
def __init__(self) -> None:
super().__init__()
self.decode_frames_calls = 0
self.decode_frames_shapes: list[tuple[int, ...]] = []
def decode_frames(self, codes: torch.Tensor) -> torch.Tensor:
self.decode_frames_calls += 1
self.decode_frames_shapes.append(tuple(codes.shape))
return torch.arange(codes.shape[-1] * 4, dtype=torch.float32).reshape(1, -1)
class _FakeMimiModel(nn.Module):
def __init__(self, _config) -> None:
super().__init__()
self.encoder_transformer = nn.Linear(1, 1, bias=False)
self.decoder_transformer = nn.Linear(1, 1, bias=False)
self.quantizer = nn.Linear(1, 1, bias=False)
self.encoder = SimpleNamespace(layers=[])
self.downsample = SimpleNamespace(conv=nn.Conv1d(1, 1, 1))
self.upsample = SimpleNamespace(conv=nn.ConvTranspose1d(1, 1, 1))
self.decoder = SimpleNamespace(layers=[])
def load_state_dict(self, _state_dict, strict: bool = True):
assert not strict
return SimpleNamespace(
missing_keys=[
"encoder_transformer.weight",
"decoder_transformer.weight",
],
unexpected_keys=[],
)
class _FakeMimiTransformer(nn.Module):
def __init__(self) -> None:
super().__init__()
self.weight = nn.Parameter(torch.zeros(1))
def load_weights(self, _state_dict, _prefix: str) -> int:
return 80
def test_moshi_checkpoint_mapping_drops_replaced_transformer_weights(
monkeypatch: pytest.MonkeyPatch,
) -> None:
mapper = getattr(personaplex_mimi, "_map_moshi_codec_weights", None)
assert callable(mapper), "PersonaPlex Mimi must load its bundled codec weights without Hugging Face"
source = {
"encoder.model.3.conv.conv.weight": torch.ones(1),
"downsample.conv.conv.conv.weight": torch.ones(1),
"quantizer.rvq_first.vq.layers.0._codebook.embedding_sum": torch.ones(1),
"quantizer.rvq_rest.vq.layers.3._codebook._initialized": torch.ones(1),
"encoder_transformer.transformer.layers.0.norm1.weight": torch.ones(1),
}
mapped = mapper(source)
assert set(mapped) == {
"encoder.layers.3.conv.weight",
"downsample.conv.weight",
"quantizer.semantic_residual_vector_quantizer.layers.0.codebook.embed_sum",
"quantizer.acoustic_residual_vector_quantizer.layers.3.codebook.initialized",
}
assert mapped["encoder.layers.3.conv.weight"] is source["encoder.model.3.conv.conv.weight"]
monkeypatch.setattr("transformers.MimiConfig", lambda: object())
monkeypatch.setattr("transformers.MimiModel", _FakeMimiModel)
monkeypatch.setattr("safetensors.torch.load_file", lambda *_args, **_kwargs: {})
monkeypatch.setattr(personaplex_mimi, "_MimiStreamingTransformer", _FakeMimiTransformer)
codec = personaplex_mimi.PersonaPlexMimiCodec(
checkpoint="/unused",
device="cpu",
)
assert not hasattr(codec.model, "encoder_transformer")
assert not hasattr(codec.model, "decoder_transformer")
assert not any(
name.startswith(("model.encoder_transformer.", "model.decoder_transformer."))
for name, _ in codec.named_parameters()
)
def test_mimi_full_stream_reset_reuses_all_state_storage_and_clears_offsets() -> None:
codec = personaplex_mimi.PersonaPlexMimiCodec.__new__(personaplex_mimi.PersonaPlexMimiCodec)
nn.Module.__init__(codec)
codec.device = torch.device("cpu")
codec.dtype = torch.float32
codec._enc_stages = [("conv", personaplex_mimi._StreamConv1d(nn.Conv1d(1, 1, 3)))]
codec._dec_stages = []
codec._downsample = personaplex_mimi._StreamConv1d(nn.Conv1d(1, 1, 3))
codec._upsample = personaplex_mimi._StreamConvTr1d(nn.ConvTranspose1d(1, 1, 3))
codec.encoder_transformer = personaplex_mimi._MimiStreamingTransformer(
num_layers=1,
dim=4,
num_heads=1,
context=3,
)
codec.decoder_transformer = personaplex_mimi._MimiStreamingTransformer(
num_layers=1,
dim=4,
num_heads=1,
context=3,
)
codec.streaming_init(batch_size=2)
conv_states = list(codec._conv_states())
conv_buffers = [
state.prev if isinstance(state, personaplex_mimi._StreamConv1d) else state.partial for state in conv_states
]
transformers = [codec.encoder_transformer, codec.decoder_transformer]
kv_states = [kv for transformer in transformers for kv in transformer._kv]
state_tensors = [
*conv_buffers,
*(kv.cache for kv in kv_states),
*(kv.end_offset for kv in kv_states),
*(kv.start_offset for kv in kv_states),
*(transformer._offset for transformer in transformers),
]
for tensor in state_tensors:
tensor.fill_(1)
for state in conv_states:
state._fresh.zero_()
storage = [tensor.data_ptr() for tensor in state_tensors]
codec.reset_streaming()
assert storage == [tensor.data_ptr() for tensor in state_tensors]
cleared = [
*conv_buffers,
*(kv.end_offset for kv in kv_states),
*(kv.start_offset for kv in kv_states),
*(transformer._offset for transformer in transformers),
]
assert all(not tensor.any() for tensor in cleared)
assert all(state._fresh.all() for state in conv_states)
def _model(*, max_sessions: int = 1) -> tuple[PersonaPlexCode2Wav, _FakeStreamingMimi]:
mimi_config = SimpleNamespace(num_codebooks=2, sample_rate=24000, samples_per_frame=4, mimi_name=None)
config = SimpleNamespace(mimi_config=mimi_config, mimi_name=None)
vllm_config = SimpleNamespace(
model_config=SimpleNamespace(
model="/unused",
hf_config=config,
duplex_max_sessions=max_sessions,
),
device_config=SimpleNamespace(device="cpu"),
)
model = PersonaPlexCode2Wav(vllm_config=vllm_config)
mimi = _FakeStreamingMimi()
model.mimi = mimi
model._mimi_device = torch.device("cpu")
return model, mimi
def _codes(frames: int, *, start: int = 0) -> torch.Tensor:
return torch.stack([torch.arange(start + offset, start + offset + frames) for offset in (0, 100)]).reshape(-1)
def _audio(output) -> torch.Tensor:
return output.multimodal_outputs["model_outputs"][0]
@pytest.mark.parametrize("second_codes", [_codes(3), _codes(1, start=100)])
def test_resumable_codes_emit_only_new_pcm(second_codes: torch.Tensor) -> None:
model, mimi = _model()
first = model(input_ids=_codes(2), request_ids=["req"])
second = model(input_ids=second_codes, request_ids=["req"])
assert _audio(first).numel() == 8
assert _audio(second).numel() == 4
assert mimi.decode_frame_calls == 3
@pytest.mark.parametrize(
("frames", "expected_shapes"),
[
(5, [(1, 2, 5)]),
(12, [(1, 2, 5), (1, 2, 5), (1, 2, 2)]),
],
)
def test_streaming_decode_uses_bounded_multi_frame_calls(
frames: int,
expected_shapes: list[tuple[int, ...]],
) -> None:
model, _ = _model()
mimi = _FakeChunkedMimi()
model.mimi = mimi
output = model(input_ids=_codes(frames), request_ids=["req"])
assert _audio(output).numel() == frames * 4
assert mimi.decode_frames_shapes == expected_shapes
assert mimi.decode_frame_calls == 0
def test_profile_inputs_skip_decode_while_malformed_online_input_warns(
monkeypatch: pytest.MonkeyPatch,
) -> None:
model, mimi = _model()
runtime_info = [{"meta": {"personaplex_dummy_profile": True}}]
warnings: list[tuple] = []
monkeypatch.setattr(personaplex_code2wav.logger, "warning", lambda *args: warnings.append(args))
output = model(
input_ids=torch.arange(3),
runtime_additional_information=runtime_info,
)
assert _audio(output).numel() == 0
assert mimi.decode_frame_calls == 0
assert warnings == []
output = model(input_ids=torch.arange(3))
assert _audio(output).numel() == 0
assert mimi.decode_frame_calls == 0
assert len(warnings) == 1
assert "not divisible by" in warnings[0][0]
assert model.get_dummy_runtime_additional_information(2) == [
{"meta": {"personaplex_dummy_profile": True}},
{"meta": {"personaplex_dummy_profile": True}},
]
def test_request_id_falls_back_to_runtime_information() -> None:
model, _ = _model()
info = [{"request_id": "runtime-req"}]
model(input_ids=_codes(2), runtime_additional_information=info)
second = model(input_ids=_codes(3), runtime_additional_information=info)
assert _audio(second).numel() == 4
def test_decoder_slot_lifecycle_isolated_capacity_and_reuse() -> None:
model, _ = _model(max_sessions=2)
first = _FakeStreamingMimi(stream_value=10)
second = _FakeStreamingMimi(stream_value=20)
model._set_mimi_codecs([first, second])
assert model._max_codec_sessions == 2
initial = model(
input_ids=torch.cat([_codes(1), _codes(1)]),
request_ids=["first", "second"],
seq_token_counts=[2, 2],
)
continued = model(
input_ids=torch.cat([_codes(1, start=10), _codes(1, start=20)]),
request_ids=["first", "second"],
seq_token_counts=[2, 2],
)
initial_audio = initial.multimodal_outputs["model_outputs"]
continued_audio = continued.multimodal_outputs["model_outputs"]
assert initial_audio[0].tolist() == [11.0] * 4
assert initial_audio[1].tolist() == [21.0] * 4
assert continued_audio[0].tolist() == [12.0] * 4
assert continued_audio[1].tolist() == [22.0] * 4
assert model._request_codec_slots == {"first": 0, "second": 1}
with pytest.raises(RuntimeError, match="decoder capacity 2 is exhausted"):
model._codec_for_request("overflow")
model.on_requests_finished(["first"])
assert first.reset_calls == 1
assert second.reset_calls == 0
replacement = model(input_ids=_codes(1), request_ids=["replacement"])
assert _audio(replacement).tolist() == [13.0] * 4
@@ -0,0 +1,364 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import base64
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
from vllm.sampling_params import SamplingParams
from tests.e2e.online_serving import personaplex_realtime_duplex as e2e_driver
from vllm_omni.config.stage_config import (
load_deploy_config,
merge_pipeline_deploy,
)
from vllm_omni.experimental.fullduplex.engine.duplex_runtime import (
DuplexInputMode,
)
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
from vllm_omni.experimental.fullduplex.personaplex.data_plane import (
PersonaPlexDataPlaneContext,
PersonaPlexDataPlaneSession,
)
from vllm_omni.experimental.fullduplex.personaplex.input import (
PersonaPlexPcmAppendBuffer,
)
from vllm_omni.experimental.fullduplex.personaplex.runtime_extension import (
PersonaPlexDuplexRuntimeExtension,
)
from vllm_omni.experimental.fullduplex.personaplex.serving_adapter import (
PersonaPlexServingRuntimeAdapter,
)
from vllm_omni.model_executor.models.personaplex.pipeline import (
PERSONAPLEX_PIPELINE,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _pcm_payload(samples: np.ndarray, *, sample_rate_hz: int = 24000) -> dict[str, object]:
samples = np.ascontiguousarray(samples, dtype="<f4")
return {
"type": "audio",
"format": "pcm_f32le",
"sample_rate_hz": sample_rate_hz,
"audio": base64.b64encode(samples.tobytes()).decode("ascii"),
}
def _audio_client(
frame_count: int = 10,
*,
voiced_frames: int = 0,
) -> SimpleNamespace:
frames = np.full((frame_count, e2e_driver.FRAME_SAMPLES), 7, dtype="<i2")
frames[:voiced_frames] = 1000
raw = frames.tobytes()
event = {
"type": "response.audio.delta",
"response_id": "response-1",
"sample_rate_hz": e2e_driver.SAMPLE_RATE_HZ,
"metadata": {
"vllm_omni": {
"runtime_impl": "scheduler_data_plane",
"uses_model_runner_scheduler": True,
"runner_kv_backed": True,
}
},
}
events = SimpleNamespace(
events=[event],
response_audio={"response-1": [raw]},
audio_bytes=lambda: raw,
)
return SimpleNamespace(events=events)
def test_realtime_audio_frame_stats_separate_voiced_and_silent_frames() -> None:
silent = np.zeros(e2e_driver.FRAME_SAMPLES, dtype="<i2")
voiced = np.full(e2e_driver.FRAME_SAMPLES, 1000, dtype="<i2")
stats = e2e_driver._audio_frame_stats(
silent.tobytes() + voiced.tobytes(),
input_frames=3,
voiced_frame_rms_threshold=1e-4,
)
assert stats == {
"output_frames": 2,
"voiced_frames": 1,
"silent_frames": 1,
"frame_deficit": 1,
"frame_coverage_ratio": pytest.approx(2 / 3),
}
def test_realtime_audio_frame_stats_reject_partial_codec_frame() -> None:
partial_frame = np.zeros(e2e_driver.FRAME_SAMPLES - 1, dtype="<i2")
with pytest.raises(AssertionError, match="whole 80 ms codec frames"):
e2e_driver._audio_frame_stats(
partial_frame.tobytes(),
input_frames=1,
voiced_frame_rms_threshold=1e-4,
)
@pytest.mark.parametrize("frame_count", [10, 40])
def test_e2e_driver_rejects_inaudible_sessions(frame_count: int) -> None:
args = SimpleNamespace(
max_frame_deficit=4,
voiced_frame_rms_threshold=1e-3,
min_voiced_frames=5,
)
with pytest.raises(AssertionError, match="audible speech"):
e2e_driver._session_result(
_audio_client(frame_count),
input_frames=frame_count,
args=args,
minimum_chunks=1,
)
def test_e2e_driver_uses_absolute_audible_floor() -> None:
args = SimpleNamespace(
max_frame_deficit=4,
voiced_frame_rms_threshold=1e-3,
min_voiced_frames=5,
)
e2e_driver._session_result(
_audio_client(200, voiced_frames=5),
input_frames=200,
args=args,
minimum_chunks=1,
)
def test_e2e_driver_defaults_replacement_to_full_input() -> None:
args = e2e_driver.parse_args(
[
"--model",
"/model",
"--input-wav",
"/input.wav",
]
)
assert args.replacement_frames == 0
assert args.min_voiced_frames == 5
def test_e2e_driver_records_and_validates_input_sha256(tmp_path: Path) -> None:
input_wav = tmp_path / "input.wav"
input_wav.write_bytes(b"fixture-wav")
identity = e2e_driver._input_identity(
input_wav,
expected_sha256="bade4b3c163edde390ff391207d34a887257d8a9cc3b621cc8c618b6e6761304",
)
assert identity == {
"path": str(input_wav.resolve()),
"sha256": "bade4b3c163edde390ff391207d34a887257d8a9cc3b621cc8c618b6e6761304",
}
def test_e2e_driver_rejects_unexpected_input_sha256(tmp_path: Path) -> None:
input_wav = tmp_path / "input.wav"
input_wav.write_bytes(b"fixture-wav")
with pytest.raises(ValueError, match="input WAV SHA-256 mismatch"):
e2e_driver._input_identity(input_wav, expected_sha256="0" * 64)
@pytest.mark.parametrize(
("attribute", "expected"),
[
(
"duplex_runtime_extension",
"vllm_omni.experimental.fullduplex.personaplex.runtime_extension.PersonaPlexDuplexRuntimeExtension",
),
(
"duplex_serving_adapter",
"vllm_omni.experimental.fullduplex.personaplex.serving_adapter.PersonaPlexServingRuntimeAdapter",
),
],
)
def test_personaplex_pipeline_registers_unified_duplex_components(
attribute: str,
expected: str,
) -> None:
assert PERSONAPLEX_PIPELINE.duplex_control_enabled is True
assert getattr(PERSONAPLEX_PIPELINE, attribute) == expected
@pytest.mark.parametrize(
("engine_arg", "expected"),
[
("skip_tokenizer_init", True),
("enable_prefix_caching", False),
],
)
def test_personaplex_stage_engine_args(
engine_arg: str,
expected: bool,
) -> None:
deploy_path = Path(__file__).parents[5] / "vllm_omni" / "deploy" / "personaplex.yaml"
stages = merge_pipeline_deploy(
PERSONAPLEX_PIPELINE,
load_deploy_config(deploy_path),
)
assert [stage.yaml_engine_args.get(engine_arg) for stage in stages] == [
expected,
expected,
]
def test_personaplex_duplex_capacity_is_propagated_to_all_model_stages() -> None:
deploy_path = Path(__file__).parents[5] / "vllm_omni" / "deploy" / "personaplex.yaml"
deploy = load_deploy_config(deploy_path)
stages = merge_pipeline_deploy(PERSONAPLEX_PIPELINE, deploy)
assert deploy.duplex_session.max_sessions == 2
assert [stage.yaml_engine_args.get("duplex_max_sessions") for stage in stages] == [2, 2]
assert "personaplex_codec_max_sessions" not in deploy.connectors["connector_of_shared_memory"]["extra"]
def test_personaplex_capabilities_are_honest() -> None:
single = PersonaPlexServingRuntimeAdapter(lambda *_: None).capabilities(max_sessions=1)
multi = PersonaPlexServingRuntimeAdapter(lambda *_: None).capabilities(max_sessions=2)
assert multi.implementation_level == "model_native_duplex"
assert multi.input_modes == ["append_audio_chunk"]
assert multi.chunk_period_ms == 80
assert single.supports_multi_session is False
assert multi.supports_multi_session is True
assert multi.supports_multi_session_same_replica is True
assert multi.supports_barge_in is False
def test_pcm_buffer_emits_one_80ms_frame_transactionally() -> None:
buffer = PersonaPlexPcmAppendBuffer()
reservation = buffer.prepare_append(
_pcm_payload(np.arange(1920, dtype=np.float32)),
operation_id="op-1",
chunk_period_ms=80,
allow_emit=True,
)
assert reservation is not None
assert reservation.byte_count == 1920 * 4
assert buffer.pending_byte_count == 0
reservation.rollback()
assert buffer.pending_byte_count == 1920 * 4
def test_pcm_buffer_rejects_changed_sample_rate() -> None:
buffer = PersonaPlexPcmAppendBuffer()
assert (
buffer.prepare_append(
_pcm_payload(np.zeros(100, np.float32)),
operation_id="op-1",
chunk_period_ms=80,
allow_emit=False,
)
is None
)
with pytest.raises(ValueError, match="sample_rate"):
buffer.prepare_append(
_pcm_payload(np.zeros(100, np.float32), sample_rate_hz=16000),
operation_id="op-2",
chunk_period_ms=80,
allow_emit=False,
)
def test_pcm_buffer_rejects_non_finite_samples() -> None:
buffer = PersonaPlexPcmAppendBuffer()
with pytest.raises(ValueError, match="finite"):
buffer.prepare_append(
_pcm_payload(np.array([0.0, np.nan], np.float32)),
operation_id="op-1",
chunk_period_ms=80,
allow_emit=False,
)
def test_runtime_extension_builds_one_frame_scheduler_append() -> None:
extension = PersonaPlexDuplexRuntimeExtension()
defaults = (
SamplingParams(temperature=0.8, top_k=10, max_tokens=10),
SamplingParams(max_tokens=1024),
)
configured = extension.configure_sampling_params(runtime_config={}, defaults=defaults)
assert configured[0].temperature == 0.0
assert configured[0].top_k == 1
assert configured[0].max_tokens == 1
assert configured[1] is defaults[1]
plan = extension.plan_append(
request_id="req",
fence=DuplexFence("session", incarnation=2, epoch=3),
session_config={"instructions": "Be concise."},
runtime_config={"personaplex_prefill_slots": 4},
seq=1,
turn_seq=1,
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
payload=_pcm_payload(np.zeros(1920, np.float32)),
final=False,
sampling_params=configured[0],
)
assert len(plan.prompt["prompt_token_ids"]) == 5
duplex = plan.prompt["model_intermediate_buffer"]["duplex"]
assert duplex["session_id"] == "session"
assert duplex["incarnation"] == 2
assert duplex["epoch"] == 3
assert duplex["seq"] == 1
assert duplex["data_plane"] is True
def test_projector_emits_only_cumulative_audio_and_text_suffixes() -> None:
encoded_sizes: list[int] = []
def encode_audio(audio, _sample_rate, _response_format, _speed):
size = int(np.asarray(audio, dtype=np.float32).size)
encoded_sizes.append(size)
return f"audio-{size}"
projector = PersonaPlexDataPlaneSession(encode_audio)
projector.begin_request("req")
context = PersonaPlexDataPlaneContext(response_format="wav")
first = SimpleNamespace(
request_id="req",
outputs=[SimpleNamespace(text="he", multimodal_output={})],
multimodal_output={"model_outputs": np.arange(4, dtype=np.float32), "sr": 24000},
finished=False,
)
second = SimpleNamespace(
request_id="req",
outputs=[SimpleNamespace(text="hello", multimodal_output={})],
multimodal_output={"model_outputs": np.arange(6, dtype=np.float32), "sr": 24000},
finished=False,
)
projected = list(
projector.project(
{"data_plane_outputs": [first, second]},
context=context,
)
)
assert encoded_sizes == [4, 2]
assert [item["text"] for item in projected] == ["he", "llo"]
assert [item["audio_data"] for item in projected] == ["audio-4", "audio-2"]
assert all(item["sample_rate_hz"] == 24000 for item in projected)
assert all(item["end_of_turn"] is False for item in projected)
@@ -0,0 +1,122 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Contract tests for the fullduplex core (PR #3907 alignment).
Covers the DuplexFence identity value, the session identity rules (epoch only
advances on barge-in, response_index once per begin_response), stale-output
dropping in the turn lifecycle. The lockstep (single-eternal-response)
lifecycle is model-owned (see
tests/e2e/features/fullduplex/test_personaplex_adapter.py).
"""
import asyncio
import dataclasses
import pytest
from vllm_omni.experimental.fullduplex.core import protocol as ev
from vllm_omni.experimental.fullduplex.core.adapter import (
DuplexAdapter,
DuplexCapability,
OutputChunk,
)
from vllm_omni.experimental.fullduplex.core.runtime import DuplexRuntime
from vllm_omni.experimental.fullduplex.core.session import (
DuplexSession,
DuplexSessionConfig,
DuplexState,
)
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_fence_is_frozen_value_identity():
f = DuplexFence(session_id="s1")
assert (f.epoch, f.turn_id, f.response_seq) == (0, 0, 0)
with pytest.raises(dataclasses.FrozenInstanceError):
f.epoch = 1 # type: ignore[misc]
assert DuplexFence("s1", 1, 2, 3) == DuplexFence("s1", 1, 2, 3)
assert DuplexFence("s1", 1, 2, 3) != DuplexFence("s1", 2, 2, 3)
def test_session_identity_rules():
s = DuplexSession(session_id="test", config=DuplexSessionConfig())
r1, e1 = s.begin_response()
r2, e2 = s.begin_response()
assert r2 == r1 + 1, "response index advances exactly once per begin"
assert e1 == e2 == 0, "epoch does not move on response begin"
s.end_response()
assert s.epoch == 0, "epoch does not move on response end"
new_epoch = s.barge_in()
assert new_epoch == 1, "epoch advances only on barge-in"
assert s.is_stale(e1) and not s.is_stale(new_epoch)
class _TurnAdapter(DuplexAdapter):
"""Emits chunks until cancelled; counts respond() lifecycles."""
def __init__(self):
self.responses = 0
self.barge_ins = 0
def capabilities(self) -> DuplexCapability:
return DuplexCapability(
input_modalities=frozenset({"audio"}),
output_modalities=frozenset({"audio"}),
proactive=True,
)
async def on_input(self, session, modality, data) -> None:
pass
def should_respond(self, session) -> bool:
return True
async def on_barge_in(self, session) -> None:
self.barge_ins += 1
async def respond(self, session):
self.responses += 1
for i in range(100):
await asyncio.sleep(0.001)
yield OutputChunk("audio", i)
async def _drive(runtime, events):
async def gen():
for e in events:
await asyncio.sleep(0.005)
yield e
out = []
async def emit(e):
out.append(e)
await runtime.run(gen(), emit)
return out
def test_turn_mode_supersede_and_stale_drop():
adapter = _TurnAdapter()
session = DuplexSession(session_id="test", config=DuplexSessionConfig(proactive=True))
runtime = DuplexRuntime(session, adapter)
out = asyncio.run(
_drive(
runtime,
[
{"type": ev.RESPONSE_CREATE},
{"type": ev.RESPONSE_CREATE}, # supersedes the first
{"type": ev.CLOSE},
],
)
)
assert adapter.responses == 2
assert adapter.barge_ins == 1, "superseding an in-flight response barges in"
created = [e for e in out if e["type"] == ev.RESPONSE_CREATED]
done = [e for e in out if e["type"] == ev.RESPONSE_DONE]
assert len(created) == 2
assert len(done) == 1, "stale first response must not emit done"
assert done[0]["response_index"] == created[1]["response_index"]
assert session.state is DuplexState.CLOSED
@@ -0,0 +1,262 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""PersonaPlex adapter driven through the model-owned lockstep runtime.
PersonaPlexDuplexRuntime starts ONE response for the whole session, streams one
agent frame per user frame, and drains on close (core stays verbatim upstream;
the lockstep lifecycle is model policy) — all with a GPU-free stub stepper.
"""
import asyncio
import numpy as np
import pytest
from vllm_omni.experimental.fullduplex.core import protocol as ev
from vllm_omni.experimental.fullduplex.core.session import (
DuplexSession,
DuplexSessionConfig,
DuplexState,
)
from vllm_omni.experimental.fullduplex.personaplex.adapter import (
PersonaPlexDuplexAdapter,
PersonaPlexDuplexRuntime,
)
from vllm_omni.experimental.fullduplex.personaplex.config import FRAME_SIZE
from vllm_omni.experimental.fullduplex.personaplex.engine import FrameOutput
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class StubStepper:
"""Deterministic FrameStepper: frame i -> audio filled with i, text 't{i}' on odds."""
sample_rate = 24000
frame_size = FRAME_SIZE
def __init__(self) -> None:
self.opened: tuple[str | None, str | None] | None = None
self.steps = 0
def open_session(self, voice_prompt=None, persona=None) -> None:
self.opened = (voice_prompt, persona)
self.steps = 0
def step(self, user_pcm):
assert user_pcm.shape[0] == self.frame_size
self.steps += 1
i = self.steps
return FrameOutput(
audio=np.full(self.frame_size, float(i), dtype=np.float32),
text=(f"t{i}" if i % 2 else None),
)
class RecordingAdapter(PersonaPlexDuplexAdapter):
def __init__(self, stepper: StubStepper) -> None:
super().__init__(stepper)
self.closed = False
async def on_close(self, session: DuplexSession) -> None:
self.closed = True
await super().on_close(session)
def _collector():
out: list[dict] = []
async def emit(event: dict) -> None:
out.append(event)
return out, emit
async def _feed(events):
for e in events:
yield e
async def _raising_feed(frame):
yield {"type": ev.INPUT_APPEND, "modality": "audio", "data": frame}
await asyncio.sleep(0)
raise RuntimeError("input failed")
def _respond_tasks() -> list[asyncio.Task]:
current = asyncio.current_task()
return [
task
for task in asyncio.all_tasks()
if task is not current and "_respond_once" in getattr(task.get_coro(), "__qualname__", "")
]
def _session():
cfg = DuplexSessionConfig(
input_modalities=("audio",),
output_modalities=("audio", "text"),
)
return DuplexSession("s", cfg)
@pytest.mark.asyncio
async def test_lockstep_one_agent_frame_per_user_frame():
stub = StubStepper()
adapter = PersonaPlexDuplexAdapter(stub, voice_prompt="NATF2.pt", persona="be terse")
rt = PersonaPlexDuplexRuntime(_session(), adapter)
out, emit = _collector()
frame = np.zeros(FRAME_SIZE, dtype=np.float32)
events = [{"type": ev.INPUT_APPEND, "modality": "audio", "data": frame} for _ in range(3)]
events.append({"type": ev.CLOSE})
await rt.run(_feed(events), emit)
types = [e["type"] for e in out]
# exactly one response for the whole session, drained cleanly on close
assert types.count(ev.RESPONSE_CREATED) == 1
assert types.count(ev.RESPONSE_DONE) == 1
audio = [e for e in out if e["type"] == ev.RESPONSE_DELTA and e["modality"] == "audio"]
text = [e["data"] for e in out if e["type"] == ev.RESPONSE_DELTA and e["modality"] == "text"]
assert len(audio) == 3 # one agent frame per user frame
assert [int(e["data"][0]) for e in audio] == [1, 2, 3] # lockstep order
assert text == ["t1", "t3"]
assert stub.steps == 3
assert stub.opened == ("NATF2.pt", "be terse")
@pytest.mark.asyncio
async def test_unaligned_chunks_are_reframed_to_80ms():
stub = StubStepper()
adapter = PersonaPlexDuplexAdapter(stub)
rt = PersonaPlexDuplexRuntime(_session(), adapter)
out, emit = _collector()
# 2.5 frames in one chunk + 0.5 frame in another => 3 whole frames
events = [
{"type": ev.INPUT_APPEND, "modality": "audio", "data": np.zeros(FRAME_SIZE * 5 // 2, dtype=np.float32)},
{"type": ev.INPUT_APPEND, "modality": "audio", "data": np.zeros(FRAME_SIZE // 2, dtype=np.float32)},
{"type": ev.CLOSE},
]
await rt.run(_feed(events), emit)
audio = [e for e in out if e["type"] == ev.RESPONSE_DELTA and e["modality"] == "audio"]
assert len(audio) == 3
assert stub.steps == 3
@pytest.mark.asyncio
async def test_close_without_input_is_clean():
adapter = PersonaPlexDuplexAdapter(StubStepper())
rt = PersonaPlexDuplexRuntime(_session(), adapter)
out, emit = _collector()
await rt.run(_feed([{"type": ev.CLOSE}]), emit)
# no input -> response never started -> no created/done, no error
assert not any(e["type"] == ev.ERROR for e in out)
assert not any(e["type"] == ev.RESPONSE_CREATED for e in out)
@pytest.mark.asyncio
async def test_iterator_eof_without_close_drains_and_stops():
# Transport drop: the input iterator ends WITHOUT an explicit CLOSE event.
# The runtime must still signal on_close so the eternal response drains
# instead of blocking on its inbox forever.
stub = StubStepper()
adapter = PersonaPlexDuplexAdapter(stub)
rt = PersonaPlexDuplexRuntime(_session(), adapter)
out, emit = _collector()
frame = np.zeros(FRAME_SIZE, dtype=np.float32)
events = [{"type": ev.INPUT_APPEND, "modality": "audio", "data": frame}]
await asyncio.wait_for(rt.run(_feed(events), emit), timeout=5.0)
types = [e["type"] for e in out]
assert types.count(ev.RESPONSE_DONE) == 1
assert stub.steps == 1
@pytest.mark.asyncio
async def test_input_exception_closes_response_and_runtime() -> None:
stub = StubStepper()
adapter = RecordingAdapter(stub)
rt = PersonaPlexDuplexRuntime(_session(), adapter)
out, emit = _collector()
frame = np.zeros(FRAME_SIZE, dtype=np.float32)
with pytest.raises(RuntimeError, match="input failed"):
await rt.run(_raising_feed(frame), emit)
leaked = _respond_tasks()
for task in leaked:
task.cancel()
if leaked:
await asyncio.gather(*leaked, return_exceptions=True)
assert adapter.closed is True
assert rt.session.state is DuplexState.CLOSED
assert not leaked
@pytest.mark.asyncio
async def test_default_session_config_gets_lockstep_lifecycle():
# The lifecycle comes from the model-owned runtime class, not a session
# flag: a plain default-constructed session config runs lockstep.
stub = StubStepper()
adapter = PersonaPlexDuplexAdapter(stub)
session = DuplexSession(
"s",
DuplexSessionConfig(input_modalities=("audio",), output_modalities=("audio", "text")),
)
rt = PersonaPlexDuplexRuntime(session, adapter)
out, emit = _collector()
frame = np.zeros(FRAME_SIZE, dtype=np.float32)
events = [{"type": ev.INPUT_APPEND, "modality": "audio", "data": frame}, {"type": ev.CLOSE}]
await rt.run(_feed(events), emit)
types = [e["type"] for e in out]
assert types.count(ev.RESPONSE_CREATED) == 1
assert types.count(ev.RESPONSE_DONE) == 1
assert stub.steps == 1
@pytest.mark.asyncio
async def test_partial_tail_is_padded_and_stepped_on_close():
# A trailing sub-frame chunk is zero-padded and stepped at close (same
# contract as PersonaPlexSession.flush) rather than silently dropped.
stub = StubStepper()
adapter = PersonaPlexDuplexAdapter(stub)
rt = PersonaPlexDuplexRuntime(_session(), adapter)
out, emit = _collector()
events = [
{"type": ev.INPUT_APPEND, "modality": "audio", "data": np.ones(FRAME_SIZE // 2, dtype=np.float32)},
{"type": ev.CLOSE},
]
await rt.run(_feed(events), emit)
audio = [e for e in out if e["type"] == ev.RESPONSE_DELTA and e["modality"] == "audio"]
assert len(audio) == 1
assert stub.steps == 1
@pytest.mark.asyncio
async def test_single_eternal_response_across_barge_in():
# Moved from the core contract tests when the lockstep lifecycle became
# model-owned: ONE response for the whole session, RESPONSE_CANCEL bumps the
# epoch fence (playback cursor) but the stream keeps flowing, and close
# drains via on_close.
stub = StubStepper()
adapter = PersonaPlexDuplexAdapter(stub)
session = _session()
rt = PersonaPlexDuplexRuntime(session, adapter)
out, emit = _collector()
frame = np.zeros(FRAME_SIZE, dtype=np.float32)
events = [
{"type": ev.INPUT_APPEND, "modality": "audio", "data": frame},
{"type": ev.RESPONSE_CANCEL}, # barge-in: epoch bumps, stream continues
{"type": ev.INPUT_APPEND, "modality": "audio", "data": frame},
{"type": ev.CLOSE},
]
await rt.run(_feed(events), emit)
types = [e["type"] for e in out]
assert types.count(ev.RESPONSE_CREATED) == 1, "exactly one eternal response"
assert types.count(ev.RESPONSE_DONE) == 1
audio = [e for e in out if e["type"] == ev.RESPONSE_DELTA and e["modality"] == "audio"]
assert len(audio) == 2, "stream keeps flowing across barge-in"
assert session.epoch == 1, "cancel still advanced the epoch fence"
assert stub.steps == 2
@@ -0,0 +1,178 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""GPU-free tests for the batched PersonaPlex slot manager (stub engine)."""
from types import SimpleNamespace
import numpy as np
import pytest
from vllm_omni.experimental.fullduplex.personaplex.engine import FrameOutput, PrefillStep
from vllm_omni.experimental.fullduplex.personaplex.serving.batched import (
BatchedSessionManager,
SlotPhase,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
FRAME = 4
class StubEngine:
"""Records the slot-API call sequence; echoes each row's pcm as its output."""
frame_size = FRAME
def __init__(self, batch_size: int = 4, prefill_len: int = 2) -> None:
self.config = SimpleNamespace(batch_size=batch_size)
self.prefill_len = prefill_len
self.recycled: list[int] = []
self.voice_ended: list[int] = []
self.prefill_done: list[int] = []
self.steps: list[tuple[np.ndarray, dict | None]] = []
def prefill_steps(self, persona=None):
steps = [PrefillStep("sacrifice"), PrefillStep("voice", embedding=None), PrefillStep("voice_end")]
steps += [PrefillStep("tokens", moshi_tokens=None, text_token=3, user_sine=True)] * self.prefill_len
return steps
def recycle_slot(self, b):
self.recycled.append(b)
def voice_end(self, b):
self.voice_ended.append(b)
def end_prefill(self, b):
self.prefill_done.append(b)
def step_batch(self, pcm, prefill=None):
self.steps.append((pcm.copy(), dict(prefill) if prefill else None))
return [FrameOutput(audio=pcm[b].copy(), text=None) for b in range(self.config.batch_size)]
def test_all_idle_does_not_step():
eng = StubEngine()
mgr = BatchedSessionManager(eng)
assert mgr.tick() is False
assert eng.steps == []
def test_pristine_slot_is_live_immediately():
eng = StubEngine()
mgr = BatchedSessionManager(eng)
slot, ready_now = mgr.acquire()
assert ready_now is True
assert slot.phase is SlotPhase.LIVE
assert eng.recycled == []
def test_custom_persona_prefills_and_recycles_on_tick_thread():
eng = StubEngine(prefill_len=2)
mgr = BatchedSessionManager(eng)
slot, ready_now = mgr.acquire(persona="custom")
assert ready_now is False
assert slot.phase is SlotPhase.PREFILL
assert eng.recycled == [] # deferred: reset must run between engine steps
ready = []
slot.on_ready = lambda: ready.append(True)
mgr.tick() # recycle + sacrifice
assert eng.recycled == [slot.index]
assert eng.steps[-1][1][slot.index].kind == "sacrifice"
mgr.tick() # voice frame
assert eng.steps[-1][1][slot.index].kind == "voice"
assert eng.voice_ended == []
mgr.tick() # voice_end executed BETWEEN ticks, then first tokens frame
assert eng.voice_ended == [slot.index]
assert eng.steps[-1][1][slot.index].kind == "tokens"
mgr.tick() # second tokens frame
assert not ready
mgr.tick() # prefill exhausted -> live
assert eng.prefill_done == [slot.index]
assert slot.phase is SlotPhase.LIVE
assert ready == [True]
def test_pristine_slot_after_ticking_requires_recycle():
# step_batch advances EVERY row each tick, so a pristine idle row that has
# lived through ticks has drifted from its boot prefill and must be
# recycled before a client may join it.
eng = StubEngine()
mgr = BatchedSessionManager(eng)
first, ready_now = mgr.acquire()
assert ready_now is True # claimed before any tick: truly pristine
mgr.tick()
second, ready_now2 = mgr.acquire()
assert second.index != first.index
assert ready_now2 is False
assert second.phase is SlotPhase.PREFILL
def test_used_slot_requires_recycle_even_with_default_persona():
eng = StubEngine()
mgr = BatchedSessionManager(eng)
slot, ready_now = mgr.acquire()
assert ready_now
mgr.release(slot)
slot2, ready_now2 = mgr.acquire()
assert slot2.index == slot.index
assert ready_now2 is False
assert slot2.phase is SlotPhase.PREFILL
def test_acquire_returns_none_when_full():
eng = StubEngine(batch_size=2)
mgr = BatchedSessionManager(eng)
assert mgr.acquire() is not None
assert mgr.acquire() is not None
assert mgr.acquire() is None
def test_feed_frames_epoch_guard_and_backlog_cap():
eng = StubEngine()
mgr = BatchedSessionManager(eng, max_buffered_frames=3)
slot, _ = mgr.acquire()
mgr.feed(slot, np.arange(FRAME * 2, dtype=np.float32), slot.epoch)
assert len(slot.inq) == 2
mgr.feed(slot, np.zeros(3, dtype=np.float32), slot.epoch) # partial tail buffered
assert len(slot.inq) == 2 and slot.pending.shape[0] == 3
mgr.feed(slot, np.zeros(FRAME * 5, dtype=np.float32), slot.epoch)
assert len(slot.inq) == 3 # oldest dropped beyond the cap
mgr.feed(slot, np.zeros(FRAME, dtype=np.float32), slot.epoch - 1) # stale epoch ignored
assert len(slot.inq) == 3
def test_live_slot_consumes_input_and_emits_output():
eng = StubEngine()
mgr = BatchedSessionManager(eng)
slot, _ = mgr.acquire()
got = []
slot.on_output = got.append
frame = np.full(FRAME, 0.5, dtype=np.float32)
mgr.feed(slot, frame, slot.epoch)
mgr.tick()
assert len(got) == 1
np.testing.assert_array_equal(got[0].audio, frame)
# queue drained: next tick feeds zeros
mgr.tick()
np.testing.assert_array_equal(got[1].audio, np.zeros(FRAME, dtype=np.float32))
def test_release_mid_flight_stops_emission():
eng = StubEngine()
mgr = BatchedSessionManager(eng)
slot, _ = mgr.acquire()
got = []
slot.on_output = got.append
mgr.tick()
assert len(got) == 1
mgr.release(slot)
# another slot keeps the batch non-idle
other, _ = mgr.acquire() # same slot index, now PREFILL (used)
assert other.phase is SlotPhase.PREFILL
mgr.tick()
assert len(got) == 1 # no further output for the released epoch
@@ -0,0 +1,240 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""GPU e2e for PersonaPlex elastic batching (bit-exact isolation + recycle).
Greedy decoding makes every cross-slot leak observable as a bit difference:
1. Isolation (bit-exact): recycling one slot mid-flight leaves every other
slot's output bit-identical to a run that never recycled.
2. Replay exactness: a recycled slot's opening live ticks match a pristine boot
at numerical-noise level (a broken KV mask / staging roll garbles frame 0).
3. Coherence (behavioral): a recycled slot fed a real spoken question produces
a real reply in its inner-monologue text stream.
Run (moshi-free native stack; ~30 GB GPU + HF access to the gated repo; set
PPLEX_QUESTION_WAV to a spoken-question wav to enable the coherence test):
pytest tests/e2e/features/fullduplex/test_personaplex_elastic.py -v -s
Bench (B sweep, ms/tick vs the 80 ms budget):
python tests/e2e/features/fullduplex/test_personaplex_elastic.py --bench 2 4 8 16
"""
from __future__ import annotations
import numpy as np
import pytest
torch = pytest.importorskip("torch")
# slow + H100 + omni routes this module into the weekly Buildkite E2E group
# ("Omni · H100" in .buildkite/cuda/test-weekly.yml: -m "slow and H100 and
# omni" --run-level full_model); the ready/merge CPU sweeps exclude it by mark.
pytestmark = [pytest.mark.full_model, pytest.mark.omni, pytest.mark.slow, pytest.mark.H100]
if not torch.cuda.is_available(): # noqa: E402
pytest.skip("needs CUDA", allow_module_level=True)
from vllm_omni.experimental.fullduplex.personaplex.config import PersonaPlexConfig # noqa: E402
from vllm_omni.experimental.fullduplex.personaplex.runtime import ( # noqa: E402
PersonaPlexEngine,
)
B = 4
LIVE_TICKS = 40 # live frames compared per claim
RECYCLE_AT = 10
def _make_engine() -> PersonaPlexEngine:
cfg = PersonaPlexConfig(batch_size=B) # native stepper is greedy-only
try:
return PersonaPlexEngine(cfg).load()
except Exception as exc: # gated-repo access is environment-dependent
name = type(exc).__name__
if "Gated" in name or "401" in str(exc) or "403" in str(exc):
pytest.skip(f"no access to the gated nvidia/personaplex-7b-v1 repo: {name}")
raise
@pytest.fixture(scope="module")
def engine() -> PersonaPlexEngine:
return _make_engine()
def _slot_stream(slot: int, tick: int, frame: int) -> np.ndarray:
"""Deterministic per-slot input: distinct low-amplitude tones."""
sr = 24000
freq = 180.0 + 90.0 * slot
t0 = tick * frame
t = (np.arange(frame) + t0) / sr
return (0.12 * np.sin(2 * np.pi * freq * t)).astype(np.float32)
def _run(engine, total_ticks: int, recycle: bool):
"""Run the lockstep loop; optionally recycle slot 1 at RECYCLE_AT.
Returns per-slot lists of (audio | None) per live tick index, where the
recycled slot's list restarts its input stream at its post-prefill tick.
"""
frame = engine.frame_size
outs: list[list[np.ndarray | None]] = [[] for _ in range(B)]
stream_tick = [0] * B # per-slot conversation clock driving its input
prefill = None
tick = 0
while tick < total_ticks:
if recycle and tick == RECYCLE_AT and prefill is None:
engine.recycle_slot(1)
prefill = list(engine.prefill_steps(None))
pcm = np.zeros((B, frame), dtype=np.float32)
step_for_1 = None
if prefill:
while prefill and prefill[0].kind == "voice_end":
engine.voice_end(1)
prefill.pop(0)
if prefill:
step_for_1 = prefill.pop(0)
else:
engine.end_prefill(1)
prefill = None
stream_tick[1] = 0 # restart slot 1's input stream from its own t=0
for b in range(B):
if b == 1 and step_for_1 is not None:
continue # engine overrides the user channel for prefill rows
pcm[b] = _slot_stream(b, stream_tick[b], frame)
stream_tick[b] += 1
frame_outs = engine.step_batch(pcm, {1: step_for_1} if step_for_1 is not None else None)
for b in range(B):
if b == 1 and step_for_1 is not None:
continue # prefill tick: nothing conversational to record
frame_out = frame_outs[b]
outs[b].append(None if frame_out is None else frame_out.audio)
tick += 1
return outs
def test_recycle_isolation_and_fresh_equivalence(engine):
engine.open_batch()
prefill_len = sum(1 for s in engine.prefill_steps(None) if s.kind != "voice_end")
total = RECYCLE_AT + prefill_len + LIVE_TICKS
base = _run(engine, total, recycle=False)
engine.open_batch() # deterministic re-boot (greedy, fixed voice/persona)
rec = _run(engine, total, recycle=True)
# Claim 1 -- isolation: untouched slots bit-identical to the no-recycle run.
for b in (0, 2, 3):
assert len(base[b]) == total and len(rec[b]) == total
for i, (x, y) in enumerate(zip(base[b], rec[b])):
assert (x is None) == (y is None), f"slot {b} tick {i} validity diverged"
if x is not None:
assert np.array_equal(x, y), f"slot {b} tick {i} audio diverged (recycle leaked)"
# Claim 2 -- replay exactness: the recycled slot's opening live ticks match a
# pristine boot at numerical-noise level. A broken KV mask / staging-ring
# roll / Mimi reset garbles the audio from the very first frame (diffs of
# 0.1-1.0); a correct replay differs only by decoder-state rounding (~1e-4).
#
# Full-length bitwise identity is NOT expected, for two structural reasons:
# the replayed system prompt sits at a different ABSOLUTE RoPE offset (bf16
# rounds differently there), and the recycled Mimi ENCODER cannot restore a
# fresh stream's start padding (a batch-shared scalar), so from the first
# non-silent user frame it emits different-but-equally-valid codes for the
# same audio. Once any near-tie flips a token, the two equally valid
# trajectories legitimately diverge; coherence of the recycled slot is
# asserted behaviorally in test_recycled_slot_converses.
fresh = [a for a in base[1] if a is not None][:LIVE_TICKS]
reborn = [a for a in rec[1][RECYCLE_AT:] if a is not None][:LIVE_TICKS]
assert len(reborn) >= LIVE_TICKS // 2, "recycled slot produced too few live frames"
diffs = [float(np.max(np.abs(x - y))) for x, y in zip(fresh, reborn)]
first_flip = next((i for i, d in enumerate(diffs) if d > 5e-2), len(diffs))
print(
f"[elastic] replay exactness: {first_flip}/{len(diffs)} opening ticks at noise level "
f"(head diffs: {[f'{d:.1e}' for d in diffs[:6]]})"
)
assert first_flip >= 4, (
f"recycled slot diverged from fresh behavior at tick {first_flip} "
f"(diffs {diffs[: first_flip + 2]}); the system-prompt replay is broken"
)
assert max(diffs[:4]) < 2e-2, f"reborn audio not at noise level: {max(diffs[:4])}"
def test_recycled_slot_converses(engine):
"""Behavioral guarantee: a recycled slot holds a coherent conversation.
Feed a real spoken question into a slot that was recycled mid-flight (while
other slots keep running); the model's own inner-monologue text stream must
produce a real reply. This is the end-user property the encoder-state
caveats above cannot break.
"""
import os
import sphn
wav_path = os.environ.get("PPLEX_QUESTION_WAV")
if not wav_path:
pytest.skip("set PPLEX_QUESTION_WAV to a spoken-question wav to run")
wav, _ = sphn.read(wav_path, sample_rate=24000)
wav = wav[0].astype(np.float32)
frame = engine.frame_size
pad = (-len(wav)) % frame
wav = np.concatenate([wav, np.zeros(pad, dtype=np.float32)])
q_frames = [wav[i : i + frame] for i in range(0, len(wav), frame)]
engine.open_batch()
pcm = np.zeros((B, frame), dtype=np.float32)
for _ in range(5): # everyone live briefly
engine.step_batch(pcm)
engine.recycle_slot(1)
prefill = list(engine.prefill_steps(None))
while prefill:
while prefill and prefill[0].kind == "voice_end":
engine.voice_end(1)
prefill.pop(0)
if not prefill:
break
engine.step_batch(pcm, {1: prefill.pop(0)})
engine.end_prefill(1)
text = ""
total = len(q_frames) + 120 # question + ~10 s to answer
for i in range(total):
pcm = np.zeros((B, frame), dtype=np.float32)
if i < len(q_frames):
pcm[1] = q_frames[i]
outs = engine.step_batch(pcm)
if outs[1] is not None and outs[1].text:
text += outs[1].text
alpha = sum(c.isalpha() for c in text)
print(f"[elastic] recycled slot inner monologue: {text!r}")
assert alpha >= 15, f"recycled slot produced no coherent reply: {text!r}"
def _bench(batch_sizes: list[int]) -> None:
import time
for bs in batch_sizes:
cfg = PersonaPlexConfig(batch_size=max(bs, 2))
eng = PersonaPlexEngine(cfg).load()
eng.open_batch()
frame = eng.frame_size
pcm = np.zeros((cfg.batch_size, frame), dtype=np.float32)
for _ in range(10): # warm
eng.step_batch(pcm)
torch.cuda.synchronize()
t0 = time.monotonic()
n = 100
for _ in range(n):
eng.step_batch(pcm)
torch.cuda.synchronize()
ms = (time.monotonic() - t0) / n * 1000
mem = torch.cuda.max_memory_allocated() / 2**30
print(f"B={cfg.batch_size:3d} {ms:6.1f} ms/tick RTF/stream {ms / 80:.3f} {mem:.1f} GiB")
del eng
torch.cuda.empty_cache()
if __name__ == "__main__":
import sys
sizes = [int(x) for x in sys.argv[2:]] if len(sys.argv) > 2 and sys.argv[1] == "--bench" else [2, 4, 8]
_bench(sizes)
@@ -0,0 +1,101 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Single-session lease teardown: a cancelled engine call must be drained.
Cancelling an asyncio task blocked on a worker-thread engine call returns
immediately while the thread keeps running (executor futures cannot be
interrupted). The server therefore tracks the in-flight future on the session
state and waits it out before releasing the capacity-one lease; otherwise a
fast reconnect could call ``open()`` on the shared engine while the previous
connection's final ``step()`` is still mutating it.
"""
import asyncio
import contextlib
import sys
import threading
import types
import pytest
try:
import sphn # noqa: F401
except ImportError:
# These lease tests never encode/decode Opus. Keep the optional serving
# dependency from hiding dependency-free teardown regressions.
sys.modules["sphn"] = types.ModuleType("sphn")
from vllm_omni.experimental.fullduplex.personaplex.config import PersonaPlexConfig
from vllm_omni.experimental.fullduplex.personaplex.serving.server import DuplexServer
from vllm_omni.experimental.fullduplex.personaplex.session import PersonaPlexServingSessionState
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@pytest.mark.asyncio
async def test_cancelled_engine_call_is_drained_before_lease_release():
server = DuplexServer(PersonaPlexConfig()) # engine never loaded: stub calls only
state = PersonaPlexServingSessionState()
started = threading.Event()
release = threading.Event()
finished = threading.Event()
def slow_engine_op():
started.set()
release.wait(timeout=5.0)
finished.set()
return []
task = asyncio.create_task(server._engine_call(state, slow_engine_op))
await asyncio.to_thread(started.wait, 5.0)
# Cancel while the worker thread is mid-call: the await returns at once,
# the thread keeps running, and its completion event stays tracked on the
# state (the asyncio future itself reads as cancelled/done here, which is
# exactly why the drain must not rely on it).
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
assert state.inflight is not None
assert not state.inflight.is_set()
# The drain must genuinely wait for the worker, not just return.
drain = asyncio.create_task(server._drain_inflight(state))
await asyncio.sleep(0.05)
assert not drain.done()
release.set()
await asyncio.wait_for(drain, timeout=5.0)
assert finished.is_set()
assert state.inflight.is_set()
@pytest.mark.asyncio
async def test_completed_engine_call_clears_inflight():
server = DuplexServer(PersonaPlexConfig())
state = PersonaPlexServingSessionState()
result = await server._engine_call(state, lambda: "ok")
assert result == "ok"
assert state.inflight is None
await server._drain_inflight(state) # no-op on a clean state
@pytest.mark.asyncio
async def test_drain_timeout_keeps_lease_until_worker_finishes():
server = DuplexServer(PersonaPlexConfig())
server._active = True
state = PersonaPlexServingSessionState()
state.inflight = threading.Event()
drained = await server._release_lease_after_drain(state, timeout=0.01)
assert drained is False
assert server._active is True
state.inflight.set()
for _ in range(100):
if not server._active:
break
await asyncio.sleep(0.01)
assert server._active is False
@@ -0,0 +1,97 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Lockstep-contract tests for the PersonaPlex session driver (no GPU / no moshi).
A stub :class:`FrameStepper` stands in for the real Moshi backend so we can assert
the framing + 1-frame-in / 1-frame-out invariants without weights.
"""
import numpy as np
import pytest
from vllm_omni.experimental.fullduplex.personaplex.config import FRAME_SIZE, PersonaPlexConfig
from vllm_omni.experimental.fullduplex.personaplex.engine import FrameOutput, FrameStepper
from vllm_omni.experimental.fullduplex.personaplex.session import PersonaPlexSession
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class StubStepper:
"""Deterministic FrameStepper: frame i -> audio filled with i, text 't{i}' on odds."""
sample_rate = 24000
frame_size = FRAME_SIZE
def __init__(self) -> None:
self.opened: tuple[str | None, str | None] | None = None
self.steps = 0
def open_session(self, voice_prompt=None, persona=None) -> None:
self.opened = (voice_prompt, persona)
self.steps = 0
def step(self, user_pcm):
assert user_pcm.shape[0] == self.frame_size
self.steps += 1
i = self.steps
return FrameOutput(
audio=np.full(self.frame_size, float(i), dtype=np.float32),
text=(f"t{i}" if i % 2 else None),
)
def test_stub_satisfies_protocol():
assert isinstance(StubStepper(), FrameStepper)
def test_feed_emits_one_output_per_complete_frame():
s = PersonaPlexSession(StubStepper(), PersonaPlexConfig())
s.open(voice_prompt="NATF2.pt", persona="be terse")
out = s.feed(np.zeros(FRAME_SIZE * 3, dtype=np.float32))
assert len(out) == 3
# lockstep order preserved: frame i -> audio filled with i
assert [int(o.audio[0]) for o in out] == [1, 2, 3]
assert [o.text for o in out] == ["t1", None, "t3"]
def test_partial_frames_are_buffered_until_complete():
stub = StubStepper()
s = PersonaPlexSession(stub, PersonaPlexConfig())
s.open()
assert s.feed(np.zeros(FRAME_SIZE // 2, dtype=np.float32)) == [] # nothing yet
assert stub.steps == 0
out = s.feed(np.zeros(FRAME_SIZE, dtype=np.float32)) # 1.5 frames total -> 1 frame
assert len(out) == 1
assert stub.steps == 1
def test_flush_pads_the_tail():
stub = StubStepper()
s = PersonaPlexSession(stub, PersonaPlexConfig())
s.open()
s.feed(np.ones(FRAME_SIZE + 10, dtype=np.float32)) # 1 frame + 10-sample tail
assert stub.steps == 1
tail = s.flush()
assert len(tail) == 1 and stub.steps == 2
assert s.flush() == [] # nothing left
def test_run_concatenates_audio_and_joins_text():
s = PersonaPlexSession(StubStepper(), PersonaPlexConfig())
s.open()
audio, text = s.run(np.zeros(FRAME_SIZE * 4, dtype=np.float32))
assert audio.shape[0] == FRAME_SIZE * 4
assert text == "t1t3" # frames 1..4, text on odds
def test_open_forwards_voice_and_persona():
stub = StubStepper()
s = PersonaPlexSession(stub, PersonaPlexConfig())
s.open(voice_prompt="NATM1.pt", persona="pirate")
assert stub.opened == ("NATM1.pt", "pirate")
def test_feed_before_open_raises():
s = PersonaPlexSession(StubStepper(), PersonaPlexConfig())
with pytest.raises(RuntimeError):
s.feed(np.zeros(FRAME_SIZE, dtype=np.float32))
@@ -0,0 +1,492 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Strict PersonaPlex validation for the unified OpenAI Realtime duplex path."""
from __future__ import annotations
import argparse
import asyncio
import base64
import hashlib
import json
import math
import uuid
import wave
from collections.abc import Sequence
from pathlib import Path
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
import numpy as np
from vllm_omni.experimental.fullduplex.client import (
RealtimeDuplexClient,
wait_for,
write_pcm16_wav,
)
SAMPLE_RATE_HZ = 24_000
FRAME_SAMPLES = 1_920
FRAME_PERIOD_S = FRAME_SAMPLES / SAMPLE_RATE_HZ
def _input_identity(
path: Path,
*,
expected_sha256: str | None,
) -> dict[str, str]:
resolved = path.resolve()
digest = hashlib.sha256()
with resolved.open("rb") as input_file:
for chunk in iter(lambda: input_file.read(1024 * 1024), b""):
digest.update(chunk)
actual = digest.hexdigest()
if expected_sha256 is not None and actual != expected_sha256.lower():
raise ValueError(
f"input WAV SHA-256 mismatch: expected={expected_sha256.lower()}, actual={actual}, path={resolved}"
)
return {"path": str(resolved), "sha256": actual}
def _realtime_url(base_url: str, model: str, session_id: str) -> str:
parts = urlsplit(base_url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query.update(duplex="1", model=model, autostart="0", session_id=session_id)
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
def _read_wav_as_float32(path: Path) -> np.ndarray:
with wave.open(str(path), "rb") as wav_file:
channels = wav_file.getnchannels()
sample_width = wav_file.getsampwidth()
source_rate = wav_file.getframerate()
frames = wav_file.readframes(wav_file.getnframes())
dtypes = {1: np.uint8, 2: np.dtype("<i2"), 4: np.dtype("<i4")}
if sample_width not in dtypes:
raise ValueError(f"unsupported WAV sample width: {sample_width}")
pcm = np.frombuffer(frames, dtype=dtypes[sample_width]).astype(np.float32)
if sample_width == 1:
pcm = (pcm - 128.0) / 128.0
else:
pcm /= float(1 << (sample_width * 8 - 1))
if channels > 1:
pcm = pcm.reshape(-1, channels).mean(axis=1)
if source_rate != SAMPLE_RATE_HZ and pcm.size:
output_samples = max(1, round(pcm.size * SAMPLE_RATE_HZ / source_rate))
pcm = np.interp(
np.linspace(0, pcm.size - 1, output_samples),
np.arange(pcm.size),
pcm,
).astype(np.float32)
pcm = np.ascontiguousarray(pcm, dtype="<f4")
if not pcm.size or not np.isfinite(pcm).all():
raise ValueError("input WAV must contain finite audio")
return pcm
def _events(client: RealtimeDuplexClient, event_type: str) -> list[dict[str, object]]:
return [event for event in client.events.events if event.get("type") == event_type]
async def _open_session(
args: argparse.Namespace,
*,
session_id: str,
persona: str,
expect_error: bool = False,
) -> tuple[RealtimeDuplexClient, dict[str, object]]:
client = RealtimeDuplexClient(_realtime_url(args.url, args.model, session_id))
await client.__aenter__()
await client.send(
{
"type": "session.update",
"session": {
"session_id": session_id,
"model": args.model,
"modalities": ["audio", "text"],
"input_audio_format": "pcm_f32le",
"output_audio_format": "pcm16",
"voice": args.voice,
"instructions": persona,
"turn_detection": None,
"extra_body": {"auto_response": True},
},
}
)
event_type = "error" if expect_error else "session.created"
await wait_for(
lambda: client.events.count(event_type) > 0,
timeout_s=args.timeout_s,
label=event_type,
)
return client, _events(client, event_type)[-1]
async def _close_session(client: RealtimeDuplexClient, *, timeout_s: float) -> None:
await client.send({"type": "session.close"})
await wait_for(
lambda: client.events.count("session.closed") > 0,
timeout_s=timeout_s,
label="session.closed",
)
await client.__aexit__(None, None, None)
async def _stream_frames(
client: RealtimeDuplexClient,
pcm: np.ndarray,
*,
max_frames: int | None = None,
) -> int:
frame_count = math.ceil(pcm.size / FRAME_SAMPLES)
if max_frames is not None:
if max_frames < 0:
raise ValueError(f"max_frames must be non-negative, got {max_frames}")
if max_frames > 0:
frame_count = min(frame_count, max_frames)
for seq in range(frame_count):
frame = pcm[seq * FRAME_SAMPLES : (seq + 1) * FRAME_SAMPLES]
frame = np.pad(frame, (0, FRAME_SAMPLES - frame.size))
frame = np.ascontiguousarray(frame, dtype="<f4")
await client.send(
{
"type": "input_audio_buffer.append",
"audio": base64.b64encode(frame).decode("ascii"),
"format": "pcm_f32le",
"sample_rate_hz": SAMPLE_RATE_HZ,
"duration_ms": 80,
"audio_end_ms": (seq + 1) * 80,
"is_speech": bool(np.sqrt(np.mean(np.square(frame))) >= 1e-4),
}
)
await asyncio.sleep(FRAME_PERIOD_S)
return frame_count
def _audio_frame_stats(
raw: bytes,
*,
input_frames: int,
voiced_frame_rms_threshold: float,
) -> dict[str, int | float]:
pcm = np.frombuffer(raw, dtype="<i2")
if pcm.size % FRAME_SAMPLES:
raise AssertionError(
"PersonaPlex output must contain whole 80 ms codec frames: "
f"samples={pcm.size}, samples_per_frame={FRAME_SAMPLES}"
)
output_frames = pcm.size // FRAME_SAMPLES
frames = pcm.astype(np.float32).reshape(output_frames, FRAME_SAMPLES) / 32768.0
voiced_frames = int(np.count_nonzero(np.sqrt(np.mean(np.square(frames), axis=1)) >= voiced_frame_rms_threshold))
return {
"output_frames": int(output_frames),
"voiced_frames": voiced_frames,
"silent_frames": int(output_frames - voiced_frames),
"frame_deficit": int(input_frames - output_frames),
"frame_coverage_ratio": float(output_frames / input_frames),
}
def _response_ids(client: RealtimeDuplexClient) -> set[str]:
return {
str(event["response_id"])
for event in _events(client, "response.audio.delta")
if isinstance(event.get("response_id"), str)
}
def _session_result(
client: RealtimeDuplexClient,
*,
input_frames: int,
args: argparse.Namespace,
minimum_chunks: int,
) -> tuple[bytes, set[str], dict[str, object]]:
chunks = sum(len(items) for items in client.events.response_audio.values())
raw = client.events.audio_bytes()
if chunks < minimum_chunks or not raw or len(raw) % 2:
raise AssertionError(f"invalid PCM16 output: chunks={chunks}, bytes={len(raw)}")
pcm = np.frombuffer(raw, dtype="<i2").astype(np.float32) / 32768.0
rms = float(np.sqrt(np.mean(np.square(pcm))))
# This low floor only catches corrupt, non-finite, or effectively zero PCM.
# Audible speech is checked per frame below with the higher 1e-3 default.
if not np.isfinite(pcm).all() or rms <= 1e-5:
raise AssertionError(f"output audio is non-finite or silent: samples={pcm.size}, rms={rms}")
rates = {
int(event["sample_rate_hz"])
for event in _events(client, "response.audio.delta")
if isinstance(event.get("sample_rate_hz"), int)
}
if rates != {SAMPLE_RATE_HZ}:
raise AssertionError(f"unexpected output sample rates: {sorted(rates)}")
stats = _audio_frame_stats(
raw,
input_frames=input_frames,
voiced_frame_rms_threshold=args.voiced_frame_rms_threshold,
)
if not 0 <= int(stats["frame_deficit"]) <= args.max_frame_deficit:
raise AssertionError(f"invalid synchronous frame accounting: input={input_frames}, stats={stats}")
min_voiced_frames = int(args.min_voiced_frames)
if min_voiced_frames < 1:
raise ValueError(f"min_voiced_frames must be positive, got {min_voiced_frames}")
if int(stats["voiced_frames"]) < min_voiced_frames:
raise AssertionError(
f"output contained insufficient audible speech: minimum_voiced_frames={min_voiced_frames}, stats={stats}"
)
runtime_metadata = [
metadata["vllm_omni"]
for event in _events(client, "response.audio.delta")
if isinstance((metadata := event.get("metadata")), dict) and isinstance(metadata.get("vllm_omni"), dict)
]
if not any(
item.get("runtime_impl") == "scheduler_data_plane"
and item.get("uses_model_runner_scheduler") is True
and item.get("runner_kv_backed") is True
for item in runtime_metadata
):
raise AssertionError(f"audio events did not prove the scheduler data plane: {runtime_metadata}")
response_ids = _response_ids(client)
if len(response_ids) != 1:
raise AssertionError(f"continuous stream used multiple responses: {sorted(response_ids)}")
return (
raw,
response_ids,
{
**stats,
"audio_chunks": chunks,
"audio_samples": len(raw) // 2,
"audio_rms": rms,
"response_ids": sorted(response_ids),
},
)
def _capabilities(created: dict[str, object]) -> dict[str, object]:
session = created.get("session")
capabilities = session.get("capabilities") if isinstance(session, dict) else None
if not isinstance(capabilities, dict):
raise AssertionError(f"session.created omitted capabilities: {created}")
return capabilities
def _save(output_dir: Path, name: str, client: RealtimeDuplexClient, raw: bytes) -> None:
(output_dir / f"{name}-events.jsonl").write_text(
"".join(json.dumps(event, ensure_ascii=False) + "\n" for event in client.events.events),
encoding="utf-8",
)
write_pcm16_wav(output_dir / f"{name}-output.wav", raw, sample_rate_hz=SAMPLE_RATE_HZ)
async def run(args: argparse.Namespace) -> dict[str, object]:
input_path = Path(args.input_wav)
input_identity = _input_identity(
input_path,
expected_sha256=args.expected_input_sha256,
)
pcm = _read_wav_as_float32(input_path)
if args.tail_s > 0:
pcm = np.concatenate([pcm, np.zeros(round(args.tail_s * SAMPLE_RATE_HZ), dtype=np.float32)])
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
ids = {name: f"personaplex-{name}-{uuid.uuid4().hex}" for name in ("primary", "secondary", "replacement")}
primary, created = await _open_session(args, session_id=ids["primary"], persona=args.persona)
secondary, secondary_created = await _open_session(
args,
session_id=ids["secondary"],
persona=args.secondary_persona,
)
capabilities = _capabilities(created)
if _capabilities(secondary_created) != capabilities:
raise AssertionError("concurrent sessions returned different capabilities")
expected_capabilities = {
"implementation_level": "model_native_duplex",
"chunk_period_ms": 80,
"supports_multi_session": True,
"supports_multi_session_same_replica": True,
"supports_barge_in": False,
}
if any(capabilities.get(key) != value for key, value in expected_capabilities.items()):
raise AssertionError(f"unexpected PersonaPlex capabilities: {capabilities}")
overflow, error = await _open_session(
args,
session_id=f"personaplex-overflow-{uuid.uuid4().hex}",
persona=args.persona,
expect_error=True,
)
error_body = error.get("error")
overflow_code = error_body.get("code") if isinstance(error_body, dict) else error.get("code")
await overflow.__aexit__(None, None, None)
if overflow_code != "resource_exhausted":
raise AssertionError(f"overflow session was not rejected: {error}")
primary_frames, secondary_frames = await asyncio.gather(
_stream_frames(primary, pcm),
_stream_frames(secondary, pcm),
)
await asyncio.gather(
wait_for(
lambda: len(primary.events.audio_bytes()) // (2 * FRAME_SAMPLES) >= primary_frames - args.max_frame_deficit,
timeout_s=args.timeout_s,
label="primary frame coverage",
),
wait_for(
lambda: (
len(secondary.events.audio_bytes()) // (2 * FRAME_SAMPLES) >= secondary_frames - args.max_frame_deficit
),
timeout_s=args.timeout_s,
label="secondary frame coverage",
),
)
await asyncio.sleep(args.drain_s)
primary_audio, primary_response_ids, primary_stats = _session_result(
primary,
input_frames=primary_frames,
args=args,
minimum_chunks=args.minimum_audio_chunks,
)
secondary_audio, secondary_response_ids, _ = _session_result(
secondary,
input_frames=secondary_frames,
args=args,
minimum_chunks=args.minimum_audio_chunks,
)
if primary_response_ids & secondary_response_ids:
raise AssertionError("concurrent sessions shared a response id")
_save(output_dir, "primary", primary, primary_audio)
await _close_session(primary, timeout_s=args.timeout_s)
replacement, replacement_created = await _open_session(
args,
session_id=ids["replacement"],
persona=args.replacement_persona,
)
if _capabilities(replacement_created) != capabilities:
raise AssertionError("replacement session returned different capabilities")
continuation_frames, replacement_frames = await asyncio.gather(
_stream_frames(secondary, pcm, max_frames=args.replacement_frames),
_stream_frames(replacement, pcm, max_frames=args.replacement_frames),
)
secondary_total_frames = secondary_frames + continuation_frames
await asyncio.gather(
wait_for(
lambda: (
len(secondary.events.audio_bytes()) // (2 * FRAME_SAMPLES)
>= secondary_total_frames - args.max_frame_deficit
),
timeout_s=args.timeout_s,
label="survivor frame coverage after slot recycle",
),
wait_for(
lambda: (
len(replacement.events.audio_bytes()) // (2 * FRAME_SAMPLES)
>= replacement_frames - args.max_frame_deficit
),
timeout_s=args.timeout_s,
label="replacement frame coverage",
),
)
await asyncio.sleep(args.drain_s)
secondary_audio, secondary_after_ids, secondary_stats = _session_result(
secondary,
input_frames=secondary_total_frames,
args=args,
minimum_chunks=args.minimum_audio_chunks + 1,
)
replacement_audio, replacement_response_ids, replacement_stats = _session_result(
replacement,
input_frames=replacement_frames,
args=args,
minimum_chunks=1,
)
if secondary_after_ids != secondary_response_ids:
raise AssertionError("survivor response changed while another session slot was recycled")
if replacement_response_ids & (primary_response_ids | secondary_response_ids):
raise AssertionError("replacement session reused another session's response id")
_save(output_dir, "secondary", secondary, secondary_audio)
_save(output_dir, "replacement", replacement, replacement_audio)
await asyncio.gather(
_close_session(secondary, timeout_s=args.timeout_s),
_close_session(replacement, timeout_s=args.timeout_s),
)
errors = primary.events.errors() + secondary.events.errors() + replacement.events.errors()
result = {
"ok": not errors,
"model": args.model,
"input": {**input_identity, "tail_s": args.tail_s},
"capabilities": capabilities,
"overflow_error_code": overflow_code,
"primary": {"session_id": ids["primary"], "input_frames": primary_frames, **primary_stats},
"secondary": {
"session_id": ids["secondary"],
"input_frames": secondary_total_frames,
"continuation_frames_after_recycle": continuation_frames,
**secondary_stats,
},
"replacement": {
"session_id": ids["replacement"],
"input_frames": replacement_frames,
**replacement_stats,
},
"errors": errors,
"output_dir": str(output_dir),
}
(output_dir / "result.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
if errors:
raise AssertionError(f"Realtime sessions emitted errors: {errors}")
return result
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--url", default="ws://127.0.0.1:8099/v1/realtime?duplex=1")
parser.add_argument("--model", required=True)
parser.add_argument("--input-wav", required=True)
parser.add_argument(
"--expected-input-sha256",
help="Fail before opening sessions when the input WAV does not match this SHA-256.",
)
parser.add_argument("--output-dir", default="/tmp/personaplex-realtime-duplex")
parser.add_argument("--voice", default="NATF2.pt")
parser.add_argument("--persona", default="You are a concise and helpful assistant.")
parser.add_argument("--secondary-persona", default="You are a calm and precise assistant.")
parser.add_argument("--replacement-persona", default="You are a cheerful travel assistant.")
parser.add_argument("--tail-s", type=float, default=2.0)
parser.add_argument("--drain-s", type=float, default=2.0)
parser.add_argument("--timeout-s", type=float, default=180.0)
parser.add_argument(
"--max-frame-deficit",
type=int,
default=4,
help=(
"Maximum unflushed tail frames. PersonaPlex Code2Wav emits five-frame "
"chunks, so a session cutoff can leave at most four pending frames."
),
)
parser.add_argument("--voiced-frame-rms-threshold", type=float, default=1e-3)
parser.add_argument(
"--min-voiced-frames",
type=int,
default=5,
help=(
"Require at least one normal five-frame Code2Wav chunk (400 ms) above "
"the per-frame RMS threshold; this detects inaudible output, not speech quality."
),
)
parser.add_argument("--minimum-audio-chunks", type=int, default=2)
parser.add_argument(
"--replacement-frames",
type=int,
default=0,
help="Frames sent after slot reuse; 0 replays the full input so the replacement must produce audible output.",
)
return parser.parse_args(argv)
def main() -> None:
print(json.dumps(asyncio.run(run(parse_args())), ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1,135 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from types import SimpleNamespace
import pytest
import torch
from vllm_omni.model_executor.models.personaplex.personaplex_talker import (
PersonaPlexTalkerForConditionalGeneration,
)
from vllm_omni.model_executor.stage_input_processors.personaplex import (
talker2code2wav_async_chunk,
talker2code2wav_full_payload,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _raw_agent_codes() -> torch.Tensor:
return torch.arange(16, dtype=torch.long).reshape(2, 8)
@pytest.mark.parametrize("source", ["pooling_output", "additional_information_cpu"])
def test_full_payload_accepts_worker_and_cached_payload_sources(source: str) -> None:
audio = _raw_agent_codes()
payload = talker2code2wav_full_payload(
pooling_output={"codes": {"audio": audio}} if source == "pooling_output" else None,
request=(
SimpleNamespace()
if source == "pooling_output"
else SimpleNamespace(additional_information_cpu={"codes": {"audio": audio}})
),
)
expected = torch.cat([audio[:-1, :1], audio[1:, 1:]], dim=1).reshape(-1)
assert torch.equal(payload.codes.audio, expected)
def test_async_chunk_keeps_delay_tail_across_resumable_segments() -> None:
manager = SimpleNamespace(
connector=SimpleNamespace(
config={
"extra": {
"initial_codec_chunk_frames": 1,
"codec_chunk_frames": 5,
}
}
)
)
request = SimpleNamespace(
request_id="req",
external_req_id="req",
resumable=True,
is_finished=lambda: True,
additional_information=None,
)
first_frame = torch.arange(8, dtype=torch.long).reshape(1, 8)
second_frame = torch.arange(8, 16, dtype=torch.long).reshape(1, 8)
request.additional_information = {"codes": {"audio": first_frame}}
first = talker2code2wav_async_chunk(
manager,
multimodal_output=None,
request=request,
is_finished=True,
)
request.additional_information = {"codes": {"audio": second_frame}}
second = talker2code2wav_async_chunk(
manager,
multimodal_output=None,
request=request,
is_finished=True,
)
assert first is not None
assert first.codes is None
assert first.meta is not None
assert first.meta.finished.item() is False
assert first.meta.is_segment_finished.item() is False
expected = torch.cat([first_frame[:, :1], second_frame[:, 1:]], dim=1).reshape(-1)
assert torch.equal(second.codes.audio, expected)
assert manager.request_payload["req"]["personaplex_frames"][0].equal(second_frame.reshape(-1))
def test_post_sample_talker_mtp_uses_current_temporal_state() -> None:
received: dict[str, torch.Tensor] = {}
recorded: list[tuple[str, torch.Tensor, torch.Tensor]] = []
def depformer(
text_token: torch.Tensor,
hidden: torch.Tensor,
*,
audio_tokens: torch.Tensor | None = None,
audio_provided: torch.Tensor | None = None,
) -> torch.Tensor:
received["text_token"] = text_token
received["hidden"] = hidden
received["audio_tokens"] = audio_tokens
received["audio_provided"] = audio_provided
return torch.arange(16, dtype=torch.long).reshape(1, 16)
model = SimpleNamespace(
_dtype=torch.float32,
depformer=depformer,
_duplex_stage0_runtime=lambda: SimpleNamespace(
record_sample=lambda *, request_id, text_token, agent_codes: recorded.append(
(request_id, text_token.clone(), agent_codes.clone())
)
),
)
method = getattr(PersonaPlexTalkerForConditionalGeneration, "post_sample_talker_mtp", None)
assert callable(method)
codes = method(
model,
input_ids=torch.tensor([101]),
hidden_states=torch.arange(4, dtype=torch.float32).reshape(1, 4),
req_ids=["r1"],
req_infos=[
{
"duplex": {"data_plane": True},
"pplex_depformer_audio_tokens": torch.arange(16),
"pplex_depformer_audio_provided": torch.arange(16) > 0,
}
],
)
assert codes.shape == (1, 16)
assert received["text_token"].tolist() == [101]
assert received["hidden"].shape == (1, 1, 4)
assert torch.equal(received["audio_tokens"], torch.arange(16).reshape(1, 16))
assert torch.equal(received["audio_provided"], (torch.arange(16) > 0).reshape(1, 16))
assert [(row[0], row[1].item()) for row in recorded] == [("r1", 101)]
assert torch.equal(recorded[0][2], codes[0])
+155
View File
@@ -1,4 +1,5 @@
import asyncio
import copy
from collections.abc import Sequence
from types import SimpleNamespace
@@ -32,6 +33,160 @@ def _make_runner(engine_output_type: str | None, downstream_req_ids: set[str]) -
return runner
def _mtp_runner(*, async_scheduling: bool, buffers: dict[str, dict]) -> tuple[GPUARModelRunner, dict[str, object]]:
received: dict[str, object] = {}
def post_sample_talker_mtp(*, input_ids, hidden_states, req_ids, req_infos):
received.update(
input_ids=input_ids.clone(),
hidden_states=hidden_states.clone(),
req_ids=list(req_ids),
req_infos=copy.deepcopy(req_infos),
)
return torch.tensor([[11, 12, 13]], dtype=torch.long)
runner = object.__new__(GPUARModelRunner)
runner.use_async_scheduling = async_scheduling
runner.model = SimpleNamespace(post_sample_talker_mtp=post_sample_talker_mtp)
runner.requests = {req_id: SimpleNamespace() for req_id in buffers}
runner.model_intermediate_buffer = buffers
return runner, received
def test_post_sample_talker_mtp_uses_current_sample_and_hidden() -> None:
runner, received = _mtp_runner(
async_scheduling=False,
buffers={
"ready": {"duplex": {"data_plane": True}},
"partial-prefill": {
"duplex": {"data_plane": True},
"codes": {"audio": torch.tensor([[99]])},
},
"offline": {},
},
)
multimodal = GPUARModelRunner._run_post_sample_talker_mtp(
runner,
req_ids=["ready", "partial-prefill", "offline"],
valid_sampled_token_ids=[[101], [], [102]],
sampled_token_ids=torch.tensor([[101], [0], [102]], dtype=torch.long),
invalid_req_indices=[],
sample_hidden_states=torch.tensor(
[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]],
),
multimodal_outputs={
"codes": {
"audio": torch.tensor(
[[70, 71, 72], [80, 81, 82], [90, 91, 92]],
dtype=torch.long,
)
},
"meta": {"source": "temporal"},
},
)
assert received["req_ids"] == ["ready"]
assert received["input_ids"].tolist() == [101]
assert received["hidden_states"].tolist() == [[1.0, 2.0]]
assert received["req_infos"] == [{"duplex": {"data_plane": True}}]
assert runner.model_intermediate_buffer["ready"]["codes"]["audio"].tolist() == [[11, 12, 13]]
# A partial-prefill row has no sampled token yet. It must not replay the
# prior frame's codes into the current connector payload.
audio = multimodal["codes"]["audio"]
assert audio[0].tolist() == [[11, 12, 13]]
assert audio[1].numel() == 0
# Mixed batches must retain outputs produced by non-duplex rows.
assert audio[2].tolist() == [[90, 91, 92]]
assert multimodal["meta"] == {"source": "temporal"}
def test_post_sample_talker_mtp_uses_gpu_token_with_async_scheduling() -> None:
runner, received = _mtp_runner(
async_scheduling=True,
buffers={
"ready": {"duplex": {"data_plane": True}},
"discarded-prefill": {"duplex": {"data_plane": True}},
},
)
multimodal = GPUARModelRunner._run_post_sample_talker_mtp(
runner,
req_ids=["ready", "discarded-prefill"],
valid_sampled_token_ids=[],
sampled_token_ids=torch.tensor([[101], [102]], dtype=torch.long),
invalid_req_indices=[1],
sample_hidden_states=torch.tensor(
[[1.0, 2.0], [3.0, 4.0]],
),
multimodal_outputs=None,
)
assert received["req_ids"] == ["ready"]
assert received["input_ids"].tolist() == [101]
assert received["hidden_states"].tolist() == [[1.0, 2.0]]
assert received["req_infos"] == [{"duplex": {"data_plane": True}}]
assert multimodal["codes"]["audio"][0].tolist() == [[11, 12, 13]]
assert multimodal["codes"]["audio"][1].numel() == 0
def test_post_sample_talker_mtp_skips_shape_validation_without_duplex_rows() -> None:
runner = object.__new__(GPUARModelRunner)
runner.use_async_scheduling = False
runner.model = SimpleNamespace(
post_sample_talker_mtp=lambda **_: pytest.fail("hook must not run"),
)
runner.model_intermediate_buffer = {"offline": {}}
multimodal = {"codes": {"audio": torch.tensor([[7, 8]], dtype=torch.long)}}
result = GPUARModelRunner._run_post_sample_talker_mtp(
runner,
req_ids=["offline"],
valid_sampled_token_ids=[[101, 102]],
sampled_token_ids=torch.tensor([[101, 102]], dtype=torch.long),
invalid_req_indices=[],
sample_hidden_states=torch.tensor([[1.0, 2.0]]),
multimodal_outputs=multimodal,
)
assert result is multimodal
@pytest.mark.parametrize(
("sampled_token_ids", "error_match"),
[
(torch.tensor([101], dtype=torch.long), "rows do not match requests"),
(
torch.tensor([[101, 102]], dtype=torch.long),
"does not support speculative token rows",
),
],
)
def test_post_sample_talker_mtp_rejects_invalid_selected_token_shape(
sampled_token_ids: torch.Tensor,
error_match: str,
) -> None:
runner = object.__new__(GPUARModelRunner)
runner.use_async_scheduling = False
runner.model = SimpleNamespace(
post_sample_talker_mtp=lambda **_: pytest.fail("hook must not run"),
)
runner.model_intermediate_buffer = {
"duplex": {"duplex": {"data_plane": True}},
}
with pytest.raises(ValueError, match=error_match):
GPUARModelRunner._run_post_sample_talker_mtp(
runner,
req_ids=["duplex"],
valid_sampled_token_ids=[[101]],
sampled_token_ids=sampled_token_ids,
invalid_req_indices=[],
sample_hidden_states=torch.tensor([[1.0, 2.0]]),
multimodal_outputs=None,
)
def test_speech_extra_params_reach_model_sampler_as_sampling_metadata(monkeypatch):
requested = {"temperature": 0.7, "top_p": 0.8, "top_k": 17}
request = OpenAICreateSpeechRequest.model_validate(
+1
View File
@@ -122,6 +122,7 @@ class OmniModelConfig(ModelConfig):
retains_state_across_chunks: bool = False
# Stage-1 active stream slots; 0 keeps legacy chunk-level round-robin.
active_stream_window: int = 0
duplex_max_sessions: int = 1
model_stage: str = "thinker"
model_arch: str | None = None
worker_type: str | None = None
+10 -1
View File
@@ -258,6 +258,7 @@ class OmniStageModelConfig:
"""Per-stage model behavior."""
active_stream_window: int = Field(default=0, ge=0)
duplex_max_sessions: int = Field(default=1, ge=1)
enable_sleep_mode: bool = False
default_sampling_params: dict[str, Any] | None = None
subtalker_sampling_params: dict[str, Any] | None = None
@@ -968,7 +969,12 @@ def _build_common_stage_config_kwargs(
return (
{
"stage_pipeline_config": topology,
"model_config": _build_model_config(topology, stage_deploy, engine.model),
"model_config": _build_model_config(
topology,
stage_deploy,
engine.model,
duplex_max_sessions=(deploy.duplex_session.max_sessions if deploy.session_mode == "duplex" else 1),
),
"load_config": _build_load_config(engine.load),
"cache_config": _build_cache_config(deploy, engine.cache),
"scheduler_config": _build_scheduler_config(deploy, engine.scheduler),
@@ -1121,6 +1127,8 @@ def _build_model_config(
topology: StagePipelineConfig,
stage_deploy: StageDeployConfig | None,
engine: _ModelEngineOverrides,
*,
duplex_max_sessions: int,
) -> OmniStageModelConfig:
default_sampling_params = _stage_sampling_params(stage_deploy, topology)
kwargs = _config_kwargs(engine)
@@ -1132,6 +1140,7 @@ def _build_model_config(
kwargs["tokenizer_subdir"] = topology.tokenizer_subdir
return OmniStageModelConfig(
default_sampling_params=default_sampling_params,
duplex_max_sessions=duplex_max_sessions,
**kwargs,
)
+2
View File
@@ -86,6 +86,7 @@ from vllm_omni.model_executor.models.moss_tts.pipeline import (
)
from vllm_omni.model_executor.models.moss_tts_nano.pipeline import MOSS_TTS_NANO_PIPELINE
from vllm_omni.model_executor.models.omnivoice.pipeline import OMNIVOICE_PIPELINE
from vllm_omni.model_executor.models.personaplex.pipeline import PERSONAPLEX_PIPELINE
from vllm_omni.model_executor.models.qwen2_5_omni.pipeline import (
QWEN2_5_OMNI_PIPELINE,
QWEN2_5_OMNI_THINKER_ONLY_PIPELINE,
@@ -113,6 +114,7 @@ OMNI_PIPELINES: dict[str, PipelineConfig | PipelineResolverFunc] = {
"aura_omni": AURA_OMNI_PIPELINE,
"qwen2_5_omni": QWEN2_5_OMNI_PIPELINE,
"qwen2_5_omni_thinker_only": QWEN2_5_OMNI_THINKER_ONLY_PIPELINE,
"personaplex": PERSONAPLEX_PIPELINE,
"qwen3_omni_moe": resolve_qwen3_omni_pipeline,
"qwen3_tts": QWEN3_TTS_PIPELINE,
"step_audio_2": STEP_AUDIO2_PIPELINE,
+5
View File
@@ -857,6 +857,11 @@ def _build_engine_args(
# Materialize the resolved pipeline-wide async_chunk value into every
# stage so explicit False overrides do not get lost downstream.
engine_args["async_chunk"] = bool(deploy.async_chunk)
if deploy.session_mode == "duplex":
# The engine admission limit is also the authoritative capacity for
# model-owned streaming state. Propagate it to every stage instead of
# making individual models duplicate the value in connector extras.
engine_args["duplex_max_sessions"] = deploy.duplex_session.max_sessions
if ps.omni_kv_config:
engine_args["omni_kv_config"] = dict(ps.omni_kv_config)
return engine_args
@@ -506,7 +506,7 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
# Finish async_chunk requests that schedule() collected because their
# upstream completed with no remaining codec tokens.
for request in self._pending_finish_reqs:
if request.is_finished():
if request in stopped_running_reqs or request.is_finished():
continue
request.status = RequestStatus.FINISHED_STOPPED
finish_reason = request.get_finished_reason()
+90
View File
@@ -0,0 +1,90 @@
# PersonaPlex deploy: talker -> code2wav (Mimi) via shared-memory chunk streaming.
#
# PersonaPlex is a Moshi finetune (full-duplex S2S), served offline as a 2-stage
# audio->audio pipeline that reuses the Qwen3-TTS staged deploy layout. Fields
# omitted from a stage fall back to StageDeployConfig defaults (see
# vllm_omni/config/stage_config.py).
#
# Notable choices for this model:
# * Stage 0 (talker) keeps a small max_num_batched_tokens for async-chunk
# first-chunk latency (not correctness).
# * Stage 1 (code2wav) wraps the external Mimi codec. Each codec frame is
# 8 active codebooks (cb 0..7) -> 1920 PCM samples @ 24 kHz / 12.5 Hz.
# * Stage 0 runs cudagraph by default; Stage 1 (Mimi) is eager because the
# external decoder performs host-to-device copies that cannot be captured.
pipeline: personaplex
session_mode: duplex
duplex_session:
max_sessions: 2
async_chunk: true
connectors:
connector_of_shared_memory:
name: SharedMemoryConnector
extra:
shm_threshold_bytes: 65536
codec_streaming: true
connector_get_sleep_s: 0.01
connector_get_max_wait_first_chunk: 3000
connector_get_max_wait: 300
# Mimi runs at 12.5 Hz (80 ms / frame). Stream a few frames per chunk.
codec_chunk_frames: 5
codec_left_context_frames: 10
# Emit only the first audio chunk early, then return to codec_chunk_frames.
initial_codec_chunk_frames: 1
stages:
- stage_id: 0
max_num_seqs: 10
gpu_memory_utilization: 0.4
trust_remote_code: true
skip_tokenizer_init: true
# Prompt IDs are placeholders; voice/persona/audio semantics live in
# request-specific embeddings, so token-ID prefix caching is invalid.
enable_prefix_caching: false
async_scheduling: true
max_num_batched_tokens: 512
# Temporal transformer sliding_window is 3000 frames; keep model len within.
max_model_len: 3000
devices: "0"
output_connectors:
to_stage_1: connector_of_shared_memory
default_sampling_params:
# Greedy talker decoding: the HeliumModel temporal matches Moshi within cos
# 0.999 (not bit-exact), and chaotic sampling amplifies that into divergence.
# Greedy is reliably coherent; raise temperature only once the temporal is
# bit-exact.
temperature: 0.0
top_k: 1
max_tokens: 3000
seed: 42
repetition_penalty: 1.0
- stage_id: 1
max_num_seqs: 10
gpu_memory_utilization: 0.3
enforce_eager: true
trust_remote_code: true
skip_tokenizer_init: true
enable_prefix_caching: false
async_scheduling: true
# Codec prefill length is num_codebooks * num_frames; keep ample headroom.
max_num_batched_tokens: 65536
max_model_len: 65536
devices: "0"
input_connectors:
from_stage_0: connector_of_shared_memory
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
max_tokens: 65536
seed: 42
repetition_penalty: 1.0
platforms:
npu:
stages:
- stage_id: 0
enforce_eager: false
+13
View File
@@ -23,6 +23,11 @@ _ARCH_TO_MODEL_TYPE: dict[str, str] = {
"IndexTTS2S2MelDecoder": "indextts2",
"IndexTTS2TalkerForConditionalGeneration": "indextts2",
"OmniVoiceModel": "omnivoice",
# PersonaPlex ships an empty config.json, so create_model_config() must patch
# model_type=personaplex from the arch name or the staged pipeline can't load
# the HF config without manual overrides.
"PersonaPlexTalkerForConditionalGeneration": "personaplex",
"PersonaPlexCode2Wav": "personaplex",
"VoxCPM2TalkerForConditionalGeneration": "voxcpm2",
}
@@ -48,6 +53,9 @@ def _register_omni_hf_configs() -> None:
MossTTSLocalConfig,
MossTTSRealtimeConfig,
)
from vllm_omni.model_executor.models.personaplex.configuration_personaplex import (
PersonaPlexConfig,
)
from vllm_omni.model_executor.models.qwen3_tts.configuration_qwen3_tts import (
Qwen3TTSConfig,
)
@@ -74,6 +82,7 @@ def _register_omni_hf_configs() -> None:
("moss_tts_local", MossTTSLocalConfig),
("moss_tts_realtime", MossTTSRealtimeConfig),
("qwen3_tts", Qwen3TTSConfig),
("personaplex", PersonaPlexConfig),
("cosyvoice3", CosyVoice3Config),
("glm_tts", GLMTTSConfig),
("omnivoice", OmniVoiceConfig),
@@ -164,6 +173,9 @@ class OmniEngineArgs(EngineArgs):
# Must be declared here so engine_args dict propagation does not silently
# drop the value when constructing OmniEngineArgs from kwargs.
active_stream_window: int = 0
# Engine admission is the single source of truth for per-stage pools of
# model-owned streaming state (codec, decoder, and similar resources).
duplex_max_sessions: int = 1
omni_kv_config: dict | None = None
quantization_config: Any | None = None
force_cutlass_fp8: bool | None = None
@@ -344,6 +356,7 @@ class OmniEngineArgs(EngineArgs):
async_chunk=self.async_chunk,
retains_state_across_chunks=self.retains_state_across_chunks,
active_stream_window=self.active_stream_window,
duplex_max_sessions=self.duplex_max_sessions,
model_stage=self.model_stage,
model_arch=self.model_arch,
worker_type=self.worker_type,
+35 -8
View File
@@ -1,10 +1,12 @@
# Experimental Full-Duplex Runtime
This package contains two experimental integrations:
This package contains three experimental integrations:
- the existing JoyVL framework and example integration;
- the MiniCPM-o 4.5 native audio path used by `/v1/duplex` and
`/v1/realtime?duplex=1`.
`/v1/realtime?duplex=1`;
- the PersonaPlex lockstep speech-to-speech path (browser demo + batched
serving, see `recipes/NVIDIA/PersonaPlex.md`).
For the MiniCPM active runtime path, lifecycle invariants, capability boundary,
Realtime response contract, and validation scope, see [`DESIGN.md`](DESIGN.md).
@@ -15,14 +17,39 @@ To run JoyVL, see
## Package boundaries
```text
core/ existing JoyVL framework and experimental compatibility exports
engine/ AsyncOmni/orchestrator scheduler data-plane adapter
openai/ WebSocket transport, Realtime projection, and audio codecs
minicpmo45/ MiniCPM input framing, policy, compatibility, and Stage0 state
joyvl/ JoyVL model-specific integration
core/ model-agnostic duplex contracts (adapter, session, turn runtime)
engine/ AsyncOmni/orchestrator scheduler data-plane adapter
openai/ WebSocket transport, Realtime projection, and audio codecs
minicpmo45/ MiniCPM input framing, policy, compatibility, and Stage0 state
joyvl/ JoyVL model-specific integration
personaplex/ PersonaPlex lockstep engine, model-owned runtime, and serving
```
MiniCPM does not run through the removed experimental `core.DuplexRuntime`
MiniCPM does not run through the experimental `core.DuplexRuntime`
facade. Its active path uses the `openai` session controller, the experimental
engine contracts, the standard scheduler/model runners, and an injected
MiniCPM-specific runtime extension from `minicpmo45/runtime.py`.
`personaplex/` is a Moshi-class, pure-lockstep speech-to-speech model on the
`core/` contracts. It keeps `core/` untouched: the lockstep lifecycle (ONE
eternal, frame-clocked response that drains on close, instead of the turn-style
start/cancel-per-trigger one) is model policy and lives in the model package as
`PersonaPlexDuplexRuntime`, mirroring the model-owned runtime shape of the
MiniCPM-o duplex work. Its runnable serving path is `personaplex/serving/`
(single-session lease or `--batch-size` elastic slots) over
`personaplex/session.py` (lockstep driver).
## Adding a full-duplex model on the core contracts
The seam is `core.DuplexAdapter`. `core/` owns the session lifecycle,
epoch-based barge-in, playback cursor, and the event protocol; you implement
only model policy.
1. Create a sibling package `vllm_omni/experimental/fullduplex/<model>/`; keep
model-specific code there and do not touch `core/`.
2. Implement one `DuplexAdapter` (`capabilities` / `on_input` / `respond`; the
rest have defaults). Turn-based models run through `core.DuplexRuntime`
unchanged; a model needing a different lifecycle carries its own runtime in
its package (see `personaplex/adapter.py::PersonaPlexDuplexRuntime`).
3. Promote a helper from a model package up into `core/` only once a second
model actually needs it.
@@ -0,0 +1,259 @@
# PersonaPlex Unified Full-Duplex Design
## Status and target
This design adapts PersonaPlex PR #4771 to the unified full-duplex runtime on
top of:
- vLLM-Omni `origin/main`: `67c54777bb22e9e7e08fdf7c47a64f06b566fc47`
- PersonaPlex PR head: `477fb7c225f0c06991bc8aa55eadbd908ba282e4`
The target is the engine-native path:
```text
/v1/realtime?duplex=1
-> OpenAI Realtime session actor
-> DuplexRequestClient
-> AsyncOmni correlated RPC
-> DuplexControlPlane
-> resumable Stage 0 request
-> PersonaPlex Talker
-> streaming PersonaPlex Code2Wav
-> PersonaPlex data-plane projector
-> response.audio.delta + response.audio_transcript.delta
```
The standalone `/api/chat` and `/v1/audio/duplex` server remains a compatibility
surface. It is not evidence that the unified engine path works.
## Why configuration-only enablement is invalid
The current staged pipeline is explicitly turn based. Its Talker reads
`pplex_user_codes`, `pplex_prefill_text`, and `pplex_silence_codes`, but no
production staged input path writes those fields. The voice prompt, persona
prefill, and streaming Mimi state live only in the standalone
`PersonaPlexEngine`.
Setting only the following fields would therefore advertise an endpoint whose
model never receives the live microphone stream:
```python
duplex_control_enabled = True
duplex_runtime_extension = "..."
duplex_serving_adapter = "..."
```
The adapter must supply a real scheduler data plane, not just endpoint
capabilities.
## Supported scope
The unified implementation supports:
- up to two engine-owned sessions on one replica;
- 24 kHz mono float PCM input;
- one 1920-sample, 80 ms model frame per physical append unit;
- continuous user input while assistant audio is generated or played;
- bundled `.pt` voice prompts and a session persona;
- greedy text and depformer sampling, matching the current PersonaPlex port;
- `/v1/realtime?duplex=1`;
- engine lease close, disconnect cleanup, reconnect after cleanup, and explicit
response cancellation without cross-session state reuse.
The implementation does not claim:
- more than two simultaneous PersonaPlex sessions on one replica;
- arbitrary WAV voice cloning;
- turn-based `response.create` semantics for an otherwise continuous model;
- scheduler migration of live codec state between replicas;
- exact output equality with the standalone engine after different scheduling
boundaries.
The capability payload derives multi-session support from the configured
session limit. The shipped two-session deployment reports
`supports_multi_session=true` and `supports_multi_session_same_replica=true`.
`duplex_session.max_sessions` is the only capacity source: config resolution
propagates it to every stage as `duplex_max_sessions`, and both Mimi pools read
that model-config value. Connector extras do not carry a second model-specific
capacity knob that could drift from engine admission.
It still reports `supports_barge_in=false`: the generic epoch fence can suppress
stale transport output, but neither PersonaPlex nor the current MiniCPM-o 4.5
adapter proves that model-owned streaming state can be destructively rewound or
restarted at a playback cursor. Continuous overlapping speech is model-native
duplex behavior, not by itself a barge-in contract.
## Components and ownership
### Serving adapter
`PersonaPlexServingRuntimeAdapter` owns only serving-side state:
- a transactional PCM append buffer;
- validation of `voice_prompt` and `instructions`;
- public capabilities;
- PersonaPlex data-plane output projection.
It accepts 24 kHz `pcm_f32le`, groups client packets into whole 1920-sample
frames, zero-pads only the final residual, and rolls a reservation back when an
engine append fails. It never loads CUDA weights and never encodes user audio.
Client input cannot provide local filesystem paths. A voice is a bundled
basename such as `NATF2.pt`; the worker resolves it under the local model
checkpoint. `instructions` is the persona string.
### Runtime extension
`PersonaPlexDuplexRuntimeExtension` is pure model policy. It:
- configures greedy Stage 0 sampling and bounded segment lengths;
- maps each accepted PCM append to a scheduler prompt;
- places immutable session identity, append sequence, PCM payload, voice, and
persona under `model_intermediate_buffer["duplex"]`;
- reserves exactly one scheduler prompt slot per encoded Mimi frame, plus the
first-append voice/persona prefill length;
- never performs model inference or owns session state.
The extension returns no turn/listen decision. PersonaPlex is an always-clocked
model, so visible audio/text comes from the final stage data plane.
### Stage 0 streaming runtime
The Talker owns a `PersonaPlexStage0DuplexRuntime` helper, analogous to
MiniCPM-o's Stage 0 helper but with PersonaPlex lockstep semantics.
For each admitted session it owns:
- streaming Mimi encoder convolution and transformer state;
- the selected voice embedding bundle;
- persona tokenization and prefill embeddings;
- the prior user code frame needed by the one-frame acoustic delay;
- append identity used to make a retried scheduler update idempotent.
The first append builds this ordered prefill:
```text
voice embeddings
-> encoded silence
-> <system> persona <system> tokens with encoded silence
-> encoded silence
-> first live user frame
```
Later appends encode only new 1920-sample frames. The helper returns the
per-frame user codes and prompt embeddings through the request's
`model_intermediate_buffer`. The normal vLLM runner remains authoritative for
attention metadata, block tables, KV allocation, scheduling, and sampling.
The Talker must distinguish resumable prompt prefill from decode positions.
Prompt rows consume the exact prepared embeddings; sampled decode rows continue
to use the existing delayed agent/user frame construction. No code path may
fall back to an all-initial user stream for a duplex request.
Cleanup is keyed by the full `(session_id, incarnation)` identity. Every live
session has an independent Mimi encoder instance; encoder convolution/KV state
is never shared between asynchronously scheduled sessions. A finished or
aborted scheduler request resets and returns only that session's encoder.
### Stage 1 streaming decoder
The current `PersonaPlexCode2Wav` calls one-shot `MimiModel.decode` and is not
CUDA-graph safe. Unified duplex uses eager Stage 1 and maintains an independent
streaming Mimi decoder for every active request. Decoder ownership is keyed by
the stable Stage 1 request id and released by `on_requests_finished`; a mixed
batch must never advance another request's convolution or transformer state.
Each Stage 0 segment emits de-delayed agent codebooks. Stage 1 decodes only the
new code frames, emits only the new PCM suffix, and resets state when the
request is closed. Connector chunk boundaries retain the final raw code frame
needed to de-delay the next chunk.
The deploy default sets Stage 1 `enforce_eager: true`; a default configuration
that fails during CUDA graph capture is not an acceptable deployment profile.
### Data-plane projector
`PersonaPlexDataPlaneSession` converts cumulative or delta Stage 1 output into
model-neutral native results:
```python
{
"stage_role": "tts",
"data_plane_request_id": request_id,
"text": text_delta,
"audio_data": encoded_audio_delta,
"audio_format": response_format,
"sample_rate_hz": 24000,
"audio_duration_ms": delta_duration,
"end_of_turn": False,
}
```
It owns per-request audio and text cursors so a cumulative output cannot replay
old audio. PersonaPlex keeps one visible response open while continuous output
arrives. Session close or cancellation terminates that response through the
generic Realtime lifecycle; a codec segment finishing is not a conversational
turn boundary.
## Error and lifecycle contracts
- Unsupported sample rate, malformed base64, non-finite PCM, invalid voice
basename, or changed format fails before scheduler submission.
- Append is prepare/submit/commit. Failure rolls back the exact reserved PCM
bytes and does not advance the model frame cursor.
- A repeated `operation_id` must not encode or submit the same frame twice.
- Input iterator exceptions execute the same cleanup as explicit close.
- Cancellation does not release the engine lease until the stage request and
any in-flight codec operation are actually finished.
- A bounded cleanup timeout returns a cleanup error and keeps the session in
the closing admission set; it must not make the slot available while work
still mutates shared state.
- New sessions cannot observe the previous voice, persona, PCM tail, Mimi
convolution state, or Talker delayed code frame.
The legacy `PersonaPlexDuplexRuntime.run()` and standalone server drain path
must follow the same exception-safe rule while they remain in the tree.
## Testing and acceptance
### Contract tests
Tests first cover:
- pipeline registration enables the control plane and selects both PersonaPlex
adapters;
- the capability payload is two-session, 80 ms, append-only, and honest;
- PCM reservation commit/rollback, partial-frame flush, invalid input, and
operation idempotency;
- runtime prompt fields and exact token budgeting;
- first-append voice/persona prefill followed by live user codes;
- later appends retain per-session Mimi state and do not replay prefill;
- interleaved Stage 0 and Stage 1 work preserves independent codec histories;
- output projection emits only audio/text deltas;
- close, exception, timeout, and reconnect cleanup;
- ordinary non-duplex imports do not load PersonaPlex modules.
### Remote H20 validation
Validation runs in an isolated remote worktree using the ModelScope
`nv-community/personaplex-7b-v1` checkpoint and its Mimi dependency.
The required evidence is:
1. default `personaplex.yaml` reaches ready without a local eager override;
2. `/health` returns 200;
3. `/v1/realtime?duplex=1` reports `model_native_duplex`, `chunk_period_ms=80`,
and two-session admission;
4. paced 24 kHz PCM appends produce finite, non-silent 24 kHz audio deltas and
text deltas;
5. microphone input continues during assistant output without cancelling the
scheduler request;
6. two paced sessions simultaneously produce independent non-empty audio, and
a third session is rejected with `resource_exhausted`;
7. closing either session frees only its scheduler request and GPU codec state,
after which a replacement session can use a different persona without state
leakage;
8. malformed input returns one typed error and does not poison the next append;
9. all focused unit tests and `git diff --check` pass.
Audio that is empty, all zero, non-finite, or only a protocol `listen` event is
not a successful end-to-end result.
@@ -0,0 +1,40 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""PersonaPlex full-duplex backend for the experimental fullduplex framework.
PersonaPlex (``nvidia/personaplex-7b-v1``) is a Moshi finetune: a pure-lockstep
speech-to-speech model. This package plugs it into the model-agnostic
``fullduplex/core`` seam without touching the engine:
- :class:`PersonaPlexConfig` immutable session config (voice / persona / sampling)
- :class:`PersonaPlexEngine` the moshi-free lockstep engine (``FrameStepper``;
single session and B concurrent slots with elastic per-slot recycle)
- :class:`PersonaPlexSession` lockstep driver (the runnable serving primitive)
- :class:`PersonaPlexDuplexAdapter` the ``core.DuplexAdapter`` for lockstep audio
- :class:`PersonaPlexDuplexRuntime` the model-owned lockstep lifecycle
(start-once / drain-on-close; ``core`` stays verbatim upstream)
"""
from vllm_omni.experimental.fullduplex.personaplex.adapter import (
PersonaPlexDuplexAdapter,
PersonaPlexDuplexRuntime,
)
from vllm_omni.experimental.fullduplex.personaplex.config import PersonaPlexConfig
from vllm_omni.experimental.fullduplex.personaplex.engine import (
FrameOutput,
FrameStepper,
PersonaPlexEngine,
)
from vllm_omni.experimental.fullduplex.personaplex.runtime import PrefillStep
from vllm_omni.experimental.fullduplex.personaplex.session import PersonaPlexSession
__all__ = [
"FrameOutput",
"FrameStepper",
"PersonaPlexConfig",
"PersonaPlexDuplexAdapter",
"PersonaPlexDuplexRuntime",
"PersonaPlexEngine",
"PersonaPlexSession",
"PrefillStep",
]
@@ -0,0 +1,177 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""PersonaPlex duplex adapter + model-owned lockstep runtime.
PersonaPlex is a *continuous* (frame-clocked, no turn-taking) model: one eternal
``respond()`` that pulls one 80 ms user frame at a time and emits one agent
frame + text token. The shared ``core.DuplexRuntime`` implements the turn
lifecycle (start/cancel per trigger), which does not fit lockstep, so the
lifecycle lives HERE as :class:`PersonaPlexDuplexRuntime` — model policy stays
in the model package, mirroring #3907's model-owned minicpmo45 runtime, and
``core`` remains verbatim upstream.
Stepping happens off the event loop (``asyncio.to_thread``, as Moshi's own
server does) so the input side stays live — that is what makes barge-in work
without any explicit flush.
Depends only on the ``FrameStepper`` protocol, so it is exercised by GPU-free
tests with a stub stepper; ``PersonaPlexEngine`` is the real backend.
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from typing import Any
import numpy as np
from vllm_omni.experimental.fullduplex.core import protocol as ev
from vllm_omni.experimental.fullduplex.core.adapter import DuplexAdapter, DuplexCapability, OutputChunk
from vllm_omni.experimental.fullduplex.core.runtime import DuplexRuntime, Emit
from vllm_omni.experimental.fullduplex.core.session import DuplexSession, DuplexState
from vllm_omni.experimental.fullduplex.personaplex.engine import FrameStepper
from vllm_omni.experimental.fullduplex.personaplex.session import MAX_PENDING_INPUT, offer_realtime
_CLOSE = object() # sentinel: drain the inbox and end the eternal response
class PersonaPlexDuplexAdapter(DuplexAdapter):
"""Drive a :class:`FrameStepper` as a continuous full-duplex response."""
def __init__(
self,
stepper: FrameStepper,
*,
voice_prompt: str | None = None,
persona: str | None = None,
) -> None:
self._stepper = stepper
self._voice_prompt = voice_prompt
self._persona = persona
self._frame_size = stepper.frame_size
# Bounded, drop-oldest: input arriving faster than the stepper can run
# must shed stale frames (realtime), not grow memory + latency unbounded.
self._inbox: asyncio.Queue[Any] = asyncio.Queue(maxsize=MAX_PENDING_INPUT)
self._pending = np.zeros(0, dtype=np.float32)
self._opened = False
def capabilities(self) -> DuplexCapability:
return DuplexCapability(
input_modalities=frozenset({"audio"}),
output_modalities=frozenset({"audio", "text"}),
proactive=True,
)
async def on_input(self, session: DuplexSession, modality: str, data: Any) -> None:
"""Frame incoming PCM into exact 80 ms windows and queue them for the loop."""
if modality != "audio":
return
samples = np.ascontiguousarray(data, dtype=np.float32).reshape(-1)
self._pending = np.concatenate([self._pending, samples]) if self._pending.size else samples
n = self._frame_size
while self._pending.shape[0] >= n:
frame, self._pending = self._pending[:n], self._pending[n:]
offer_realtime(self._inbox, frame)
async def respond(self, session: DuplexSession) -> AsyncIterator[OutputChunk]:
"""The eternal lockstep loop: one user frame in, one agent frame + text out."""
if not self._opened:
await asyncio.to_thread(self._stepper.open_session, self._voice_prompt, self._persona)
self._opened = True
while True:
frame = await self._inbox.get()
if frame is _CLOSE:
break
out = await asyncio.to_thread(self._stepper.step, frame)
if out.audio is not None:
yield OutputChunk("audio", out.audio)
if out.text:
yield OutputChunk("text", out.text)
async def on_close(self, session: DuplexSession) -> None:
# Model-owned drain signal (not part of the core DuplexAdapter ABC):
# PersonaPlexDuplexRuntime calls this on every exit path. Pad + flush a
# partial final frame (same contract as PersonaPlexSession.flush()) so
# the tail of the user's audio is stepped, not dropped.
if self._pending.size:
pad = self._frame_size - self._pending.shape[0]
frame = np.concatenate([self._pending, np.zeros(pad, dtype=np.float32)])
self._pending = np.zeros(0, dtype=np.float32)
offer_realtime(self._inbox, frame)
offer_realtime(self._inbox, _CLOSE)
async def on_barge_in(self, session: DuplexSession) -> None:
# No-op: Moshi-class models perceive the user continuously, so an interrupt
# needs no downstream flush — the next frames simply steer the model.
return
class PersonaPlexDuplexRuntime(DuplexRuntime):
"""Model-owned lockstep lifecycle: start once, never supersede, drain on exit.
``core.DuplexRuntime`` runs the turn lifecycle — every trigger starts a new
response that supersedes and cancels the previous one. A lockstep model has
exactly ONE response for the whole session: it consumes user frames as they
arrive and only ends when the input side goes away. Keeping this here (not
as a flag on the shared runtime) mirrors #3907's model-owned minicpmo45
runtime: model policy lives in the model package.
"""
async def run(self, inputs: AsyncIterator[dict], emit: Emit) -> None:
task: asyncio.Task | None = None
try:
async for event in inputs:
etype = event.get("type")
if etype == ev.INPUT_APPEND:
modality = event.get("modality", "")
if modality not in self._capabilities.input_modalities:
await emit(ev.error(f"unsupported input modality: {modality}"))
continue
await self.adapter.on_input(self.session, modality, event.get("data"))
self.session.state = DuplexState.LISTENING
task = self._ensure_started(task, emit)
elif etype in (ev.INPUT_COMMIT, ev.RESPONSE_CREATE):
task = self._ensure_started(task, emit)
elif etype == ev.RESPONSE_CANCEL:
# Barge-in is native for lockstep models: bump the epoch for the
# playback cursor but keep the single response streaming.
await self._barge_in()
await emit(ev.cancelled(self.session.response_index))
elif etype == ev.PLAYBACK_ACK:
await self.adapter.on_playback_ack(self.session, int(event.get("cursor", 0)))
elif etype == ev.CLOSE:
break
finally:
# Drain on EVERY exit path: CLOSE, iterator EOF, transport errors,
# and cancellation must not leave the eternal response blocked on
# its inbox or the session outside CLOSED.
try:
if task is not None and not task.done():
await self.adapter.on_close(self.session)
await task
finally:
self.session.state = DuplexState.CLOSED
def _ensure_started(self, task: asyncio.Task | None, emit: Emit) -> asyncio.Task:
# Start the single eternal response exactly once; never supersede it.
if task is None or task.done():
return asyncio.create_task(self._respond_once(emit))
return task
async def _respond_once(self, emit: Emit) -> None:
# One response for the whole session. No staleness checks: the loop ends
# only when the adapter's respond() returns (after on_close drains its
# input).
response_index, _ = self.session.begin_response()
await emit(ev.created(response_index))
try:
async for chunk in self.adapter.respond(self.session):
await emit(ev.delta(response_index, chunk.modality, chunk.data))
except asyncio.CancelledError:
raise
except Exception as err:
await emit(ev.error(f"response failed: {err}"))
finally:
await emit(ev.done(response_index))
self.session.end_response()
@@ -0,0 +1,64 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Configuration for the PersonaPlex full-duplex backend.
PersonaPlex (``nvidia/personaplex-7b-v1``) is a Moshi finetune: a pure-lockstep
full-duplex speech-to-speech model running at the Mimi codec frame rate
(12.5 Hz / 80 ms). One config drives both the offline driver and the duplex
adapter. Defaults mirror the PersonaPlex reference loop.
"""
from __future__ import annotations
from dataclasses import dataclass
# Mimi codec constants (loaders.py: SAMPLE_RATE / FRAME_RATE). Fixed by the
# pretrained checkpoint; exposed here only so the rest of the package never
# hard-codes them.
SAMPLE_RATE = 24000
FRAME_RATE = 12.5
FRAME_SIZE = int(SAMPLE_RATE / FRAME_RATE) # 1920 samples per 80 ms frame
# Default assistant persona shipped with PersonaPlex (offline.py:335).
DEFAULT_PERSONA = "You are a wise and friendly teacher. Answer questions or provide advice in a clear and engaging way."
@dataclass(frozen=True)
class PersonaPlexConfig:
"""Immutable session configuration for a PersonaPlex conversation.
Attributes:
hf_repo: HuggingFace repo holding the weights, Mimi codec and tokenizer.
voice_prompt: Voice-clone reference. Either a bundled basename
(``"NATF2.pt"`` / ``"NATM1.pt"`` from ``voices.tgz``) or a path to a
``.pt`` embedding bundle or a reference ``.wav``.
persona: System role text; injected as ``<system> ... <system>`` into the
inner-monologue stream at session start.
device: Torch device for the backend (``"cuda"`` / ``"cpu"``).
cpu_offload: Offload LM layers to CPU when GPU memory is tight (needs
``accelerate``).
batch_size: Concurrent conversation slots sharing one engine. ``1`` is
the single-session path; ``> 1`` enables elastic batching with
per-slot recycle for new callers.
Note: the native stepper decodes greedily (argmax) for both the text head
and the depformer, so there are no sampling knobs here yet. Temperature /
top-k / seed fields will be added if and when a sampling path is wired in.
"""
hf_repo: str = "nvidia/personaplex-7b-v1"
voice_prompt: str = "NATF2.pt"
persona: str = DEFAULT_PERSONA
device: str = "cuda"
cpu_offload: bool = False
batch_size: int = 1
@property
def sample_rate(self) -> int:
return SAMPLE_RATE
@property
def frame_size(self) -> int:
return FRAME_SIZE
@@ -0,0 +1,228 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
from collections.abc import Callable, Iterator, Mapping
from dataclasses import dataclass
import numpy as np
from vllm_omni.experimental.fullduplex.engine.contracts import (
duplex_resource_request_belongs_to_session,
)
EncodeAudio = Callable[[object, int, str, float | None], str | None]
@dataclass(frozen=True, slots=True)
class PersonaPlexDataPlaneContext:
epoch: int = 0
turn_id: int = 0
active_response_turn_id: int | None = None
active_response_id: str | None = None
auto_responds: bool = True
response_format: str = "wav"
speed: float | None = None
modalities: tuple[str, ...] = ("audio", "text")
@dataclass(slots=True)
class _RequestCursor:
audio_samples: int = 0
text: str = ""
terminal: bool = False
class PersonaPlexDataPlaneSession:
"""Project cumulative staged PersonaPlex output into Realtime deltas."""
def __init__(self, encode_audio: EncodeAudio) -> None:
self._encode_audio = encode_audio
self._requests: dict[str, _RequestCursor] = {}
def begin_request(self, request_id: str) -> None:
self._requests.setdefault(request_id, _RequestCursor()).terminal = False
def is_terminal(self, request_id: str | None) -> bool:
if request_id is None:
return False
state = self._requests.get(request_id)
return state is not None and state.terminal
def mark_terminal(self, request_id: str) -> None:
self._requests.setdefault(request_id, _RequestCursor()).terminal = True
def close_stream(self, request_id: str) -> None:
self._requests.pop(request_id, None)
def close_session(self, session_id: str, *, active_request_id: str | None = None) -> None:
if active_request_id is not None:
self._requests.pop(active_request_id, None)
for request_id in list(self._requests):
if duplex_resource_request_belongs_to_session(request_id, session_id):
self._requests.pop(request_id, None)
def project(
self,
result: object,
*,
context: PersonaPlexDataPlaneContext | None = None,
) -> Iterator[dict[str, object]]:
if not isinstance(result, dict):
return
outputs = result.get("data_plane_outputs")
if not isinstance(outputs, list):
return
context = context or PersonaPlexDataPlaneContext()
for output in outputs:
projected = self._project_output(output, context=context)
if projected is not None:
yield projected
def _project_output(
self,
output: object,
*,
context: PersonaPlexDataPlaneContext,
) -> dict[str, object] | None:
output, completion = _unwrap_output(output)
request_id = getattr(output, "request_id", None)
if not isinstance(request_id, str) or not request_id:
request_id = None
state = self._requests.setdefault(request_id, _RequestCursor()) if request_id is not None else _RequestCursor()
multimodal = _multimodal_output(output, completion)
audio = _audio_value(multimodal)
audio_delta = _slice_audio(audio, state.audio_samples)
audio_samples = _num_samples(audio)
if audio_samples is not None:
state.audio_samples = audio_samples
text = _text_value(multimodal, completion)
text_delta = _text_delta(text, state.text)
if text:
state.text = text
sample_rate_hz = _sample_rate(multimodal)
encoded = self._encode_audio(audio_delta, sample_rate_hz, context.response_format, context.speed)
if not encoded and not text_delta:
return None
delta_samples = _num_samples(audio_delta) or 0
return {
"supported": True,
"stage_role": "tts",
"is_listen": False,
"data_plane_request_id": request_id,
"text": text_delta,
"audio_data": encoded or "",
"audio_format": context.response_format,
"sample_rate_hz": sample_rate_hz,
"audio_duration_ms": round(delta_samples * 1000 / max(1, sample_rate_hz)),
"end_of_turn": False,
"uses_model_runner_scheduler": True,
"runner_kv_backed": True,
"runtime_impl": "scheduler_data_plane",
"owned_runtime": False,
}
def _unwrap_output(output: object) -> tuple[object, object | None]:
inner = getattr(output, "request_output", None)
if inner is not None and inner is not output:
output = inner
outputs = getattr(output, "outputs", None)
completion = outputs[0] if isinstance(outputs, list) and outputs else None
return output, completion
def _multimodal_output(output: object, completion: object | None) -> dict[str, object]:
for candidate in (
getattr(output, "multimodal_output", None),
getattr(completion, "multimodal_output", None) if completion is not None else None,
):
if isinstance(candidate, Mapping) and candidate:
return dict(candidate)
return {}
def _audio_value(multimodal: dict[str, object]) -> object | None:
value = next(
(multimodal[key] for key in ("audio", "model_outputs", "latent") if key in multimodal),
None,
)
if isinstance(value, list) and len(value) == 1:
return value[0]
return value
def _text_value(multimodal: dict[str, object], completion: object | None) -> str:
for candidate in (
multimodal.get("text"),
multimodal.get("llm_output_text"),
getattr(completion, "text", None) if completion is not None else None,
):
if isinstance(candidate, str) and candidate:
return candidate
return ""
def _text_delta(text: str, previous: str) -> str:
if not text:
return ""
if text == previous:
return ""
if previous and text.startswith(previous):
return text[len(previous) :]
return text
def _slice_audio(audio: object | None, offset: int) -> object | None:
samples = _num_samples(audio)
if samples is None or samples <= 0:
return None
if offset <= 0 or samples < offset:
return audio
if samples == offset:
return None
try:
import torch
if isinstance(audio, torch.Tensor):
return audio.reshape(-1)[offset:].contiguous()
except Exception:
pass
return np.asarray(audio, dtype=np.float32).reshape(-1)[offset:]
def _num_samples(audio: object | None) -> int | None:
if audio is None:
return None
try:
import torch
if isinstance(audio, torch.Tensor):
return int(audio.numel())
except Exception:
pass
try:
return int(np.asarray(audio, dtype=np.float32).size)
except Exception:
return None
def _sample_rate(multimodal: dict[str, object]) -> int:
value = multimodal.get("sr", multimodal.get("sample_rate_hz", 24000))
if isinstance(value, list) and value:
value = value[0]
if hasattr(value, "item"):
try:
value = value.item()
except Exception:
value = 24000
return int(value) if isinstance(value, int | float) else 24000
__all__ = [
"PersonaPlexDataPlaneContext",
"PersonaPlexDataPlaneSession",
]
@@ -0,0 +1,74 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""PersonaPlex duplex protocol home (moshi-free).
Defines the narrow ``FrameStepper`` seam (one 80 ms user frame in, one agent
frame + text piece out) that the session driver, the duplex adapter and the
GPU-free tests depend on, and re-exports the native lockstep engine that
implements it. The implementation lives in ``runtime.py`` and is built
entirely from vllm-omni-native components (streaming Helium temporal, input
embeddings, depformer, streaming Mimi codec) — the external ``moshi`` package
is no longer used.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Protocol, runtime_checkable
import numpy as np
if TYPE_CHECKING:
from numpy.typing import NDArray
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class FrameOutput:
"""Result of stepping the model by exactly one 80 ms frame.
Attributes:
audio: Agent PCM for this frame (``frame_size`` mono float32 samples), or
``None`` during the model's initial delay/warmup frames.
text: Inner-monologue text piece for this frame, or ``None`` when the
model emitted a padding token (silence between words).
"""
audio: NDArray[np.float32] | None
text: str | None
@runtime_checkable
class FrameStepper(Protocol):
"""The single seam between PersonaPlex and the duplex framework.
A stepper consumes one user audio frame and returns one agent frame. It owns
all conversation state (KV caches / streaming buffers); callers never re-feed
history. ``PersonaPlexEngine`` is the real implementation; tests pass a stub.
"""
sample_rate: int
frame_size: int
def open_session(self, voice_prompt: str | None = None, persona: str | None = None) -> None:
"""Reset streaming state and inject the voice clone + persona prompt."""
...
def step(self, user_pcm: NDArray[np.float32]) -> FrameOutput:
"""Advance the conversation by one frame of exactly ``frame_size`` samples."""
...
from vllm_omni.experimental.fullduplex.personaplex.runtime import ( # noqa: E402
PersonaPlexEngine,
PrefillStep,
)
__all__ = [
"FrameOutput",
"FrameStepper",
"PersonaPlexEngine",
"PrefillStep",
]
@@ -0,0 +1,213 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import base64
import binascii
import numpy as np
_SAMPLE_RATE_HZ = 24000
_FRAME_SAMPLES = 1920
_BYTES_PER_SAMPLE = 4
class PersonaPlexPcmAppendReservation:
__slots__ = (
"_active",
"_owner",
"_raw",
"operation_id",
"payload",
)
def __init__(
self,
*,
owner: PersonaPlexPcmAppendBuffer,
operation_id: str,
payload: dict[str, object] | None,
raw: bytes,
) -> None:
self._owner = owner
self.operation_id = operation_id
self.payload = payload
self._raw = raw
self._active = True
@property
def active(self) -> bool:
return self._active
@property
def byte_count(self) -> int:
return len(self._raw)
def commit(self) -> None:
self._owner._commit_reservation(self)
def rollback(self) -> None:
self._owner._rollback_reservation(self)
class PersonaPlexPcmAppendBuffer:
"""Transactionally frame 24 kHz float PCM into PersonaPlex 80 ms units."""
def __init__(self) -> None:
self._buffer = bytearray()
self._sample_rate_hz: int | None = None
self._reservations: list[PersonaPlexPcmAppendReservation] = []
@property
def pending_byte_count(self) -> int:
return len(self._buffer)
def has_pending(self) -> bool:
return bool(self._buffer)
def has_reserved(self) -> bool:
return any(reservation.active for reservation in self._reservations)
def clear_force_listen(self) -> None:
return
def clear(self) -> None:
for reservation in self._reservations:
reservation._active = False
self._reservations.clear()
self._buffer.clear()
self._sample_rate_hz = None
def prepare_append(
self,
payload: dict[str, object],
*,
operation_id: str,
chunk_period_ms: int,
allow_emit: bool,
) -> PersonaPlexPcmAppendReservation | None:
if chunk_period_ms != 80:
raise ValueError("PersonaPlex chunk_period_ms must be 80")
if any(reservation.active and reservation.operation_id == operation_id for reservation in self._reservations):
raise ValueError(f"PersonaPlex duplicate active operation_id: {operation_id}")
raw = self._decode_payload(payload)
self._buffer.extend(raw)
if not allow_emit or len(self._buffer) < _FRAME_SAMPLES * _BYTES_PER_SAMPLE:
return None
return self._reserve_frame(payload, operation_id=operation_id, flush=False)
def prepare_commit(
self,
*,
operation_id: str,
chunk_period_ms: int,
) -> PersonaPlexPcmAppendReservation:
if chunk_period_ms != 80:
raise ValueError("PersonaPlex chunk_period_ms must be 80")
if not self._buffer:
reservation = PersonaPlexPcmAppendReservation(
owner=self,
operation_id=operation_id,
payload=None,
raw=b"",
)
self._reservations.append(reservation)
return reservation
payload: dict[str, object] = {
"type": "audio",
"format": "pcm_f32le",
"sample_rate_hz": self._sample_rate_hz or _SAMPLE_RATE_HZ,
"audio": "",
"final": True,
}
return self._reserve_frame(payload, operation_id=operation_id, flush=True)
def flush(self, *, chunk_period_ms: int) -> dict[str, object] | None:
reservation = self.prepare_commit(
operation_id="personaplex-flush",
chunk_period_ms=chunk_period_ms,
)
payload = reservation.payload
reservation.commit()
return payload
def _decode_payload(self, payload: dict[str, object]) -> bytes:
if payload.get("format") != "pcm_f32le":
raise ValueError("PersonaPlex input format must be pcm_f32le")
sample_rate_hz = payload.get("sample_rate_hz")
if sample_rate_hz != _SAMPLE_RATE_HZ:
raise ValueError("PersonaPlex sample_rate_hz must be 24000")
if self._sample_rate_hz is not None and self._sample_rate_hz != sample_rate_hz:
raise ValueError("PersonaPlex sample_rate_hz changed within a session")
audio = payload.get("audio")
if not isinstance(audio, str):
raise ValueError("PersonaPlex audio must be base64 pcm_f32le")
try:
raw = base64.b64decode(audio, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError("PersonaPlex audio is not valid base64") from exc
if len(raw) % _BYTES_PER_SAMPLE:
raise ValueError("PersonaPlex pcm_f32le byte length must be divisible by four")
samples = np.frombuffer(raw, dtype="<f4")
if not np.isfinite(samples).all():
raise ValueError("PersonaPlex pcm_f32le samples must be finite")
self._sample_rate_hz = int(sample_rate_hz)
return raw
def _reserve_frame(
self,
source_payload: dict[str, object],
*,
operation_id: str,
flush: bool,
) -> PersonaPlexPcmAppendReservation:
frame_bytes = _FRAME_SAMPLES * _BYTES_PER_SAMPLE
if flush:
consumed_bytes = min(len(self._buffer), frame_bytes)
else:
consumed_bytes = frame_bytes
raw = bytes(self._buffer[:consumed_bytes])
del self._buffer[:consumed_bytes]
encoded_raw = raw + b"\x00" * (frame_bytes - consumed_bytes)
payload = dict(source_payload)
payload["type"] = "audio"
payload["format"] = "pcm_f32le"
payload["sample_rate_hz"] = _SAMPLE_RATE_HZ
payload["audio"] = base64.b64encode(encoded_raw).decode("ascii")
reservation = PersonaPlexPcmAppendReservation(
owner=self,
operation_id=operation_id,
payload=payload,
raw=raw,
)
self._reservations.append(reservation)
return reservation
def _commit_reservation(self, reservation: PersonaPlexPcmAppendReservation) -> None:
if not reservation.active:
return
reservation._active = False
self._reservations.remove(reservation)
def _rollback_reservation(self, reservation: PersonaPlexPcmAppendReservation) -> None:
if not reservation.active:
return
try:
index = self._reservations.index(reservation)
except ValueError:
reservation._active = False
return
rolled_back = self._reservations[index:]
restored = b"".join(item._raw for item in rolled_back if item.active)
self._buffer[:0] = restored
for item in rolled_back:
item._active = False
del self._reservations[index:]
__all__ = [
"PersonaPlexPcmAppendBuffer",
"PersonaPlexPcmAppendReservation",
]
@@ -0,0 +1,60 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""PersonaPlex frame/token contract (the model *policy*, no engine state).
This mirrors the ``minicpmo45`` model-package layout from PR #3907: the token
contract lives in ``policy.py``, the model-owned runtime in ``runtime.py``.
Everything here is a constant or a pure function of the checkpoint's lockstep
conventions; nothing touches weights, devices, or streaming state.
Frame layout per 80 ms tick: 17 token rows -- row 0 inner-monologue text,
rows 1-8 agent audio codebooks, rows 9-16 user audio codebooks.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
# Inner-monologue token ids that carry no displayable text (EPAD, PAD).
NON_TEXT_TOKEN_IDS = frozenset({0, 3})
ZERO_TEXT_TOKEN = 3
# Mimi token constants for the hybrid system prompt (agent silence / user sine).
SILENCE_TOKENS = (948, 243, 1178, 546, 1736, 1030, 1978, 2008)
SINE_TOKENS = (430, 1268, 381, 1611, 1095, 1495, 56, 472)
# LM initial ("beginning of stream") token ids: audio rows use the codec
# cardinality, the text row uses the text vocabulary size.
AUDIO_INITIAL_TOKEN = 2048
TEXT_INITIAL_TOKEN = 32000
TEXT_VOCAB_SIZE = 32000
AUDIO_SILENCE_FRAME_CNT = 6 # 0.5 s at 12.5 Hz
@dataclass(frozen=True)
class PrefillStep:
"""One tick of a recycled slot's system-prompt replay (see batched serving)."""
kind: str # "sacrifice" | "voice" | "voice_end" | "tokens"
embedding: Any = None # [1, 1, H] tensor for kind == "voice"
moshi_tokens: Any = None # [8] tensor for kind == "tokens"
text_token: int | None = None
user_sine: bool = False
def wrap_with_system_tags(text: str) -> str:
t = text.strip()
return t if t.startswith("<system>") else f"<system> {t} <system>"
__all__ = [
"AUDIO_INITIAL_TOKEN",
"AUDIO_SILENCE_FRAME_CNT",
"NON_TEXT_TOKEN_IDS",
"PrefillStep",
"SILENCE_TOKENS",
"SINE_TOKENS",
"TEXT_INITIAL_TOKEN",
"TEXT_VOCAB_SIZE",
"ZERO_TEXT_TOKEN",
"wrap_with_system_tags",
]
@@ -0,0 +1,473 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Moshi-free PersonaPlex lockstep engine (native temporal + depformer + Mimi).
This is the real-time duplex backend built entirely from vllm-omni-native
components: :class:`PersonaPlexTemporalStreaming` (ring-KV Helium),
:class:`PersonaPlexInputEmbeddings`, :class:`PersonaPlexDepformer` and
:class:`PersonaPlexMimiCodec`. It reimplements the reference lockstep semantics
(staging ring, per-codebook delays, warmup gates, hybrid system-prompt prefill,
per-slot elastic recycle) as first-class policy code, so the external ``moshi``
package is no longer needed at runtime.
Lockstep contract per 80 ms tick (17 token rows: text, 8 agent codebooks, 8
user codebooks; delays ``[0, 1x8-ish from checkpoint]``):
1. Mimi-encode the user frame and write it (delayed) into the staging ring.
2. Read the model input at ``(o - 1) % CT``, embed, step the temporal.
3. Greedy text token; depformer expands the 16 audio rows (teacher-forced where
the staging ring already holds provided values).
4. Write sampled rows at ``o % CT``; gather the de-delayed output frame; decode
agent rows 1..8 through Mimi (rows still in warmup are sanitized to silence
tokens: the LM's initial audio token id equals the codec cardinality and is
out of range for the codebook tables).
Elastic recycle mirrors the verified design from the moshi-hosted path: the
temporal ring KV masks the recycled row (plus the one LM "sacrifice tick"
write), the staging row is wiped to initial tokens, and the system prompt
(voice embeddings, silence spacer, persona tokens, silence) is replayed per-row
while other slots keep conversing.
"""
from __future__ import annotations
import logging
import os
import tarfile
import threading
from pathlib import Path
from typing import TYPE_CHECKING
import numpy as np
from vllm_omni.experimental.fullduplex.personaplex.config import PersonaPlexConfig
from vllm_omni.experimental.fullduplex.personaplex.policy import (
AUDIO_INITIAL_TOKEN,
AUDIO_SILENCE_FRAME_CNT,
NON_TEXT_TOKEN_IDS,
SILENCE_TOKENS,
SINE_TOKENS,
TEXT_INITIAL_TOKEN,
TEXT_VOCAB_SIZE,
ZERO_TEXT_TOKEN,
PrefillStep,
wrap_with_system_tags,
)
if TYPE_CHECKING:
from numpy.typing import NDArray
logger = logging.getLogger(__name__)
class PersonaPlexEngine:
"""Frame-lockstep PersonaPlex on native components only (no moshi).
One class serves both surfaces: the single-session ``FrameStepper`` protocol
(``open_session`` / ``step``) and the batched slot API consumed by
``serving/batched.py`` (``open_batch`` / ``prefill_steps`` / ``recycle_slot``
/ ``voice_end`` / ``end_prefill`` / ``step_batch``).
"""
def __init__(self, config: PersonaPlexConfig | None = None) -> None:
self.config = config or PersonaPlexConfig()
self.sample_rate = self.config.sample_rate
self.frame_size = self.config.frame_size
# Serializes the single-session FrameStepper surface (open_session/step)
# across worker threads: a cancelled asyncio task cannot stop a running
# engine call, so a racing open() must wait for the in-flight step.
# The batched surface is serialized by BatchedSessionManager instead.
self._fs_lock = threading.Lock()
self._loaded = False
self._opened = False
self._tokenizer = None
self._codec = None
self._temporal = None
self._emb = None
self._dep = None
self._voice_embeddings = None # [T, 1, 1, H]
self._voice_cache = None # [1, K, CT]
self._loaded_voice = None # name/path of the currently loaded voice
# lockstep state (built at open time)
self._cache = None # [B, K, CT] long
self._provided = None # [B, K, CT] bool
self._initial = None # [K] long
self._delays = None # [K] long (python list)
self._phys = 0
self._local = None # [B] cpu long
# -- loading --------------------------------------------------------------
def load(self) -> PersonaPlexEngine:
if self._loaded:
return self
import sentencepiece
import torch
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
from vllm_omni.model_executor.models.personaplex.configuration_personaplex import (
PersonaPlexConfig as _PPModelConfig,
)
from vllm_omni.model_executor.models.personaplex.personaplex_depformer import (
PersonaPlexDepformer,
)
from vllm_omni.model_executor.models.personaplex.personaplex_embeddings import (
PersonaPlexInputEmbeddings,
)
from vllm_omni.model_executor.models.personaplex.personaplex_mimi import (
PersonaPlexMimiCodec,
)
from vllm_omni.model_executor.models.personaplex.personaplex_temporal import (
PersonaPlexTemporalStreaming,
)
cfg = self.config
B = cfg.batch_size
logger.info("PersonaPlex native: loading Mimi codec")
self._codec = PersonaPlexMimiCodec(device=cfg.device)
self._codec.streaming_init(B)
tok_path = hf_hub_download(cfg.hf_repo, "tokenizer_spm_32k_3.model")
self._tokenizer = sentencepiece.SentencePieceProcessor(tok_path)
logger.info("PersonaPlex native: loading temporal + depformer + embeddings")
sd = load_file(hf_hub_download(cfg.hf_repo, "model.safetensors"), device=cfg.device)
dtype = torch.bfloat16
self._temporal = PersonaPlexTemporalStreaming().to(cfg.device, dtype)
self._temporal.load_weights(sd)
self._temporal.eval()
self._temporal.streaming_init(B)
ppcfg = _PPModelConfig()
self._emb = PersonaPlexInputEmbeddings(ppcfg).to(cfg.device, dtype)
self._emb.load_weights(sd)
self._emb.eval()
self._dep = PersonaPlexDepformer(ppcfg.depformer_config, temporal_hidden_size=4096, text_card=32000).to(
cfg.device, dtype
)
self._dep.load_weights(sd)
self._dep.eval()
del sd
torch.accelerator.empty_cache()
K = 17
self._delays = [0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1] # text, agent cb0..7, user cb0..7
self._initial = torch.tensor(
[TEXT_INITIAL_TOKEN] + [AUDIO_INITIAL_TOKEN] * 16, dtype=torch.long, device=cfg.device
)
ct = max(self._delays) + 3
self._cache = torch.full((B, K, ct), AUDIO_INITIAL_TOKEN, dtype=torch.long, device=cfg.device)
self._provided = torch.zeros((B, K, ct), dtype=torch.bool, device=cfg.device)
self._local = torch.zeros(B, dtype=torch.long)
self._sine = torch.tensor(SINE_TOKENS, dtype=torch.long, device=cfg.device)
self._silence = torch.tensor(SILENCE_TOKENS, dtype=torch.long, device=cfg.device)
self._load_voice(cfg.voice_prompt)
self._loaded = True
return self
def _load_voice(self, voice: str) -> None:
import torch
if voice == self._loaded_voice:
return
path = self._resolve_voice_prompt(voice)
if not path.endswith(".pt"):
raise ValueError(f"native stack needs a .pt voice-embedding bundle; got {path!r}")
state = torch.load(path, map_location=self.config.device, weights_only=True)
self._voice_embeddings = state["embeddings"].to(self.config.device)
self._voice_cache = state["cache"].to(self.config.device)
self._loaded_voice = voice
def _resolve_voice_prompt(self, voice: str) -> str:
from huggingface_hub import hf_hub_download
if os.path.exists(voice):
return voice
tgz = Path(hf_hub_download(self.config.hf_repo, "voices.tgz"))
vdir = tgz.parent / "voices"
if not vdir.exists():
with tarfile.open(tgz, "r:gz") as tar:
tar.extractall(path=tgz.parent) # noqa: S202 - trusted first-party bundle
cand = vdir / voice
if not cand.exists():
raise FileNotFoundError(f"voice prompt {voice!r} not found in voices.tgz")
return str(cand)
# -- lockstep core ----------------------------------------------------------
def _reset_all_streaming(self) -> None:
self._codec.reset_streaming()
self._temporal.reset_streaming()
self._cache.fill_(AUDIO_INITIAL_TOKEN)
self._cache[:, 0, :] = TEXT_INITIAL_TOKEN
self._provided.fill_(False)
self._phys = 0
self._local.zero_()
def _tick(
self,
user_codes, # [B, 8] long or None (prefill rows overridden separately)
forced_moshi=None, # [B, 8] long (values used on force_mask rows)
forced_text=None, # [B] long
force_mask=None, # [B] bool (device)
embed_override=None, # ([B, 1, H], [B] bool rows)
):
"""One lockstep step. Returns (ran, out[B,17] | None)."""
import torch
B, K, CT = self._cache.shape
o = self._phys
lo = self._local
if user_codes is not None:
for q in range(8):
k = 9 + q
wp = (o + self._delays[k]) % CT
self._cache[:, k, wp] = user_codes[:, q]
self._provided[:, k, wp] = True
if forced_moshi is not None and force_mask is not None:
for q in range(8):
k = 1 + q
wp = (o + self._delays[k]) % CT
self._cache[force_mask, k, wp] = forced_moshi[force_mask, q]
self._provided[force_mask, k, wp] = True
if forced_text is not None and force_mask is not None:
wp = (o + self._delays[0]) % CT
self._cache[force_mask, 0, wp] = forced_text[force_mask]
self._provided[force_mask, 0, wp] = True
for k, delay in enumerate(self._delays):
warm = lo <= delay # [B] cpu bool
if bool(warm.any()):
wd = warm.to(self._cache.device)
pos = o % CT
self._cache[:, k, pos] = torch.where(wd, self._initial[k].expand(B), self._cache[:, k, pos])
self._provided[:, k, pos] = self._provided[:, k, pos] | wd
if o == 0:
# Genuine cold start: no prior physical frame to read as model input.
self._cache[:, :, 0] = self._initial.view(1, K).expand(B, K)
self._phys += 1
self._local += 1
return False, None
mip = (o - 1) % CT
tp = o % CT
input_ = self._cache[:, :, mip] # [B, K]
target_ = self._cache[:, :, tp]
provided_ = self._provided[:, :, tp]
emb = self._emb(input_.unsqueeze(-1)) # [B, 1, H]
if embed_override is not None:
ov, rows = embed_override
emb = torch.where(rows.view(B, 1, 1), ov.to(emb.dtype), emb)
hidden, text_logits = self._temporal.step(emb)
sampled_text = text_logits.float().view(B, -1).argmax(-1) # greedy
next_text = torch.where(provided_[:, 0], target_[:, 0], sampled_text)
audio = self._dep(next_text, hidden, audio_tokens=target_[:, 1:], audio_provided=provided_[:, 1:]) # [B, 16]
self._provided[:, :, mip] = False
self._cache[:, 0, tp] = torch.where(~provided_[:, 0], sampled_text, self._cache[:, 0, tp])
self._cache[:, 1:, tp] = torch.where(~provided_[:, 1:], audio, self._cache[:, 1:, tp])
max_delay = max(self._delays)
valid = lo > max_delay
if not bool(valid.any()):
self._phys += 1
self._local += 1
return True, None
idx = torch.tensor(
[(self._phys - max_delay + d) % CT for d in self._delays],
device=self._cache.device,
dtype=torch.long,
)
out = self._cache.gather(2, idx.view(1, K, 1).expand(B, K, 1)).squeeze(-1) # [B, K]
self._last_valid = valid.clone()
self._phys += 1
self._local += 1
return True, out
def _boot_prefill(self, persona: str | None) -> None:
"""Batch-wide hybrid system prompt: voice -> cache restore -> silence ->
persona -> silence (all rows identical)."""
import torch
B = self.config.batch_size
dev = self.config.device
dummy_user = self._initial[9:].view(1, 8).expand(B, 8)
dummy_moshi = self._initial[1:9].view(1, 8).expand(B, 8)
zero_text = torch.full((B,), ZERO_TEXT_TOKEN, dtype=torch.long, device=dev)
all_rows = torch.ones(B, dtype=torch.bool, device=dev)
for e in self._voice_embeddings:
ov = e.view(1, 1, -1).expand(B, 1, -1)
while True:
ran, _ = self._tick(dummy_user, dummy_moshi, zero_text, all_rows, embed_override=(ov, all_rows))
if ran:
break
self._cache.copy_(self._voice_cache.expand_as(self._cache))
sine = self._sine.view(1, 8).expand(B, 8)
silence = self._silence.view(1, 8).expand(B, 8)
text = persona if persona is not None else self.config.persona
toks = self._tokenizer.encode(wrap_with_system_tags(text)) if text else []
for phase_toks in (
[ZERO_TEXT_TOKEN] * AUDIO_SILENCE_FRAME_CNT,
toks,
[ZERO_TEXT_TOKEN] * AUDIO_SILENCE_FRAME_CNT,
):
for t in phase_toks:
tt = torch.full((B,), int(t), dtype=torch.long, device=dev)
self._tick(sine, silence, tt, all_rows)
self._codec.reset_streaming()
# -- batched surface (mirrors the previous engine's API) --------------------
def open_batch(self) -> None:
if not self._loaded:
self.load()
self._reset_all_streaming()
self._boot_prefill(None)
self._opened = True
logger.info("PersonaPlex native: %d slots prefilled", self.config.batch_size)
def prefill_steps(self, persona: str | None = None) -> list[PrefillStep]:
zero = self._silence
silence = [
PrefillStep("tokens", moshi_tokens=zero, text_token=ZERO_TEXT_TOKEN, user_sine=True)
for _ in range(AUDIO_SILENCE_FRAME_CNT)
]
steps: list[PrefillStep] = [PrefillStep("sacrifice")]
for e in self._voice_embeddings:
steps.append(PrefillStep("voice", embedding=e.view(1, 1, -1)))
steps.append(PrefillStep("voice_end"))
steps.extend(silence)
text = persona if persona is not None else self.config.persona
if text:
for tok in self._tokenizer.encode(wrap_with_system_tags(text)):
steps.append(PrefillStep("tokens", moshi_tokens=zero, text_token=int(tok), user_sine=True))
steps.extend(silence)
return steps
def recycle_slot(self, b: int) -> None:
self._temporal.reset_slot(b)
self._temporal.bump_slot_start(b) # LM sacrifice-tick semantics
self._local[b] = 0
self._cache[b] = self._initial.view(-1, 1).expand(-1, self._cache.shape[2])
self._provided[b] = False
def voice_end(self, b: int) -> None:
import torch
ct = self._cache.shape[2]
shift = int((self._phys - int(self._local[b])) % ct)
self._cache[b] = torch.roll(self._voice_cache[0], shifts=shift, dims=-1)
def end_prefill(self, b: int) -> None:
self._codec.reset_slot(b)
def step_batch(self, user_pcm: NDArray[np.float32], prefill: dict[int, PrefillStep] | None = None):
import torch
if not self._opened:
raise RuntimeError("open_batch() must be called before step_batch()")
cfg = self.config
B = cfg.batch_size
pcm = np.ascontiguousarray(user_pcm, dtype=np.float32).reshape(B, self.frame_size)
codes = self._codec.encode_frame(torch.from_numpy(pcm).to(cfg.device)).long()
return self._step_from_codes(codes, prefill)
def step_codes(self, user_codes, prefill: dict[int, PrefillStep] | None = None):
"""Drive the lockstep loop from precomputed user codes (golden replays).
Kept public deliberately: bit-exact golden-replay verification must
bypass the Mimi encoder, whose streaming conv state makes encoded
codes run-order dependent (valid but not reproducible token-for-token).
"""
return self._step_from_codes(user_codes.to(self.config.device).long(), prefill)
def _step_from_codes(self, codes, prefill):
import torch
from vllm_omni.experimental.fullduplex.personaplex.engine import FrameOutput
cfg = self.config
B = cfg.batch_size
dev = cfg.device
forced_moshi = forced_text = force_mask = embed_override = None
if prefill:
force_mask = torch.zeros(B, dtype=torch.bool, device=dev)
embed_rows = torch.zeros(B, dtype=torch.bool, device=dev)
emb_full = None
forced_moshi = self._initial[1:9].view(1, 8).expand(B, 8).clone()
forced_text = torch.full((B,), ZERO_TEXT_TOKEN, dtype=torch.long, device=dev)
for b, stp in prefill.items():
if stp.kind == "voice_end":
raise ValueError("voice_end is not a tick; call engine.voice_end(b)")
codes[b] = self._sine if stp.user_sine else self._initial[9:]
if stp.kind == "voice":
embed_rows[b] = True
if emb_full is None:
emb_full = torch.zeros(B, 1, stp.embedding.shape[-1], dtype=stp.embedding.dtype, device=dev)
emb_full[b] = stp.embedding[0]
force_mask[b] = True # dummy staging like step_embeddings
else:
force_mask[b] = True
if stp.kind == "tokens":
forced_moshi[b] = stp.moshi_tokens
forced_text[b] = int(stp.text_token)
if bool(embed_rows.any()):
embed_override = (emb_full, embed_rows)
ran, out = self._tick(codes, forced_moshi, forced_text, force_mask, embed_override)
self.last_out_tokens = None if out is None else out.detach().cpu()
if not ran or out is None:
return [None] * B
valid = self._last_valid # [B] cpu bool
emit = [bool(valid[b]) and (not prefill or b not in prefill) for b in range(B)]
audio_tokens = out[:, 1:9].clone()
if not all(emit):
hide = torch.tensor([not e for e in emit], device=dev).view(B, 1)
audio_tokens = torch.where(hide, self._silence.view(1, 8).expand(B, 8), audio_tokens)
pcm_out = self._codec.decode_frame(audio_tokens) # [B, frame]
outs = []
for b in range(B):
if not emit[b]:
outs.append(None)
continue
audio = pcm_out[b].float().detach().cpu().numpy().astype(np.float32)
outs.append(FrameOutput(audio=audio, text=self._decode_text(int(out[b, 0].item()))))
return outs
# -- single-session FrameStepper surface -------------------------------------
def open_session(self, voice_prompt: str | None = None, persona: str | None = None) -> None:
with self._fs_lock:
if not self._loaded:
self.load()
# Always resolve a voice: falling back to the configured default here
# keeps a previous session's custom voice from leaking into this one.
self._load_voice(voice_prompt or self.config.voice_prompt)
self._reset_all_streaming()
self._boot_prefill(persona)
self._opened = True
def step(self, user_pcm: NDArray[np.float32]):
with self._fs_lock:
pcm = np.ascontiguousarray(user_pcm, dtype=np.float32).reshape(-1)
if pcm.shape[0] != self.frame_size:
raise ValueError(f"expected {self.frame_size} samples per frame, got {pcm.shape[0]}")
outs = self.step_batch(pcm.reshape(1, -1))
from vllm_omni.experimental.fullduplex.personaplex.engine import FrameOutput
return outs[0] if outs[0] is not None else FrameOutput(audio=None, text=None)
def _decode_text(self, token_id: int) -> str | None:
if token_id in NON_TEXT_TOKEN_IDS or token_id >= TEXT_VOCAB_SIZE:
return None
return self._tokenizer.id_to_piece(token_id).replace("▁", " ")
@@ -0,0 +1,129 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import base64
import binascii
from typing import Any
from vllm.sampling_params import SamplingParams
from vllm_omni.experimental.fullduplex.engine.duplex_runtime import (
DuplexAppendPlan,
DuplexInputMode,
DuplexOutputDecision,
)
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
_FRAME_BYTES = 1920 * 4
def _validated_frame_payload(payload: object) -> dict[str, object]:
if not isinstance(payload, dict):
raise ValueError("PersonaPlex duplex append payload must be a mapping")
if payload.get("format") != "pcm_f32le":
raise ValueError("PersonaPlex duplex append format must be pcm_f32le")
if payload.get("sample_rate_hz") != 24000:
raise ValueError("PersonaPlex duplex append sample_rate_hz must be 24000")
audio = payload.get("audio")
if not isinstance(audio, str):
raise ValueError("PersonaPlex duplex append audio must be base64 pcm_f32le")
try:
raw = base64.b64decode(audio, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError("PersonaPlex duplex append audio is not valid base64") from exc
if len(raw) != _FRAME_BYTES:
raise ValueError("PersonaPlex duplex append must contain exactly 1920 samples")
return dict(payload)
class PersonaPlexDuplexRuntimeExtension:
"""PersonaPlex policy for the engine-owned resumable duplex request."""
def configure_sampling_params(
self,
*,
runtime_config: dict[str, Any],
defaults: tuple[object, ...],
) -> tuple[object, ...]:
del runtime_config
if not defaults:
return defaults
configured = list(defaults)
stage0 = defaults[0]
if isinstance(stage0, SamplingParams):
stage0 = stage0.clone()
stage0.temperature = 0.0
stage0.top_k = 1
stage0.max_tokens = 1
configured[0] = stage0
return tuple(configured)
def plan_append(
self,
*,
request_id: str,
fence: DuplexFence,
session_config: dict[str, Any],
runtime_config: dict[str, Any],
seq: int,
turn_seq: int,
mode: DuplexInputMode,
payload: object,
final: bool,
sampling_params: object,
) -> DuplexAppendPlan:
del sampling_params
if mode is not DuplexInputMode.APPEND_AUDIO_CHUNK:
raise ValueError(f"PersonaPlex does not support duplex input mode {mode.value!r}")
normalized_payload = _validated_frame_payload(payload)
prefill_slots = runtime_config.get("personaplex_prefill_slots", 0)
try:
prefill_slots = max(0, int(prefill_slots))
except (TypeError, ValueError) as exc:
raise ValueError("personaplex_prefill_slots must be a non-negative integer") from exc
prompt_slots = 1 + (prefill_slots if seq <= 1 else 0)
return DuplexAppendPlan(
prompt={
"prompt_token_ids": [0] * prompt_slots,
"model_intermediate_buffer": {
"request_id": request_id,
"global_request_id": [fence.session_id],
"duplex": {
"data_plane": True,
"fence": fence,
"session_id": fence.session_id,
"incarnation": fence.incarnation,
"epoch": fence.epoch,
"turn_id": fence.turn_id,
"response_seq": fence.response_seq,
"seq": seq,
"turn_seq": turn_seq,
"mode": mode.value,
"payload": normalized_payload,
"final": final,
"session_config": dict(session_config),
"runtime_config": dict(runtime_config),
"scheduler_token_budget": prompt_slots,
},
},
}
)
def decide_output(
self,
*,
stage_id: int,
final_stage_id: int,
segment_finished: bool,
segment_token_ids: tuple[int, ...],
segment_output_metadata: dict[str, Any],
output: object,
) -> DuplexOutputDecision | None:
del stage_id, final_stage_id, segment_finished
del segment_token_ids, segment_output_metadata, output
return None
__all__ = ["PersonaPlexDuplexRuntimeExtension"]
@@ -0,0 +1,15 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""PersonaPlex full-duplex WebSocket serving (PCM in / PCM + text out)."""
from typing import Any
__all__ = ["create_app", "main"]
def __getattr__(name: str) -> Any:
if name not in __all__:
raise AttributeError(name)
from vllm_omni.experimental.fullduplex.personaplex.serving import server
return getattr(server, name)
@@ -0,0 +1,184 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Slot manager + lockstep tick loop for batched PersonaPlex serving.
Pure-python orchestration over a :class:`PersonaPlexEngine`-shaped object
(injected, so tests run GPU-free with a stub): a fixed table of B conversation
slots, one shared wall-clock 80 ms tick that advances every non-idle slot, and
per-slot recycle prefill for new callers.
Threading model: ``tick()`` runs on a dedicated stepping thread (the engine call
is synchronous CUDA work); ``acquire``/``release``/``feed`` are called from the
asyncio thread. All slot state is guarded by one lock; per-frame output is
handed off via each slot's ``on_output`` callback (called on the stepping
thread -- the server bridges to asyncio with ``call_soon_threadsafe``).
"""
from __future__ import annotations
import enum
import logging
import threading
import time
from collections import deque
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
import numpy as np
logger = logging.getLogger(__name__)
class SlotPhase(enum.Enum):
IDLE = "idle"
PREFILL = "prefill"
LIVE = "live"
@dataclass
class Slot:
index: int
phase: SlotPhase = SlotPhase.IDLE
used: bool = False # a used slot must be recycled before reuse
needs_recycle: bool = False # engine.reset executed by the tick thread only
epoch: int = 0
pending: np.ndarray = field(default_factory=lambda: np.zeros(0, dtype=np.float32))
inq: deque = field(default_factory=deque)
prefill: deque = field(default_factory=deque)
on_output: Callable[[Any], None] | None = None # FrameOutput, stepping thread
on_ready: Callable[[], None] | None = None # prefill finished, stepping thread
class BatchedSessionManager:
"""Own the slot table and drive one engine tick per call to :meth:`tick`."""
def __init__(self, engine, max_buffered_frames: int = 25) -> None:
self.engine = engine
self.frame_size = engine.frame_size
self.batch_size = engine.config.batch_size
self.max_buffered_frames = max_buffered_frames
self.slots = [Slot(index=i) for i in range(self.batch_size)]
self.lock = threading.Lock()
# step_batch advances EVERY row each tick (lockstep), so once any tick has
# run, "pristine" idle rows have silently drifted from their boot prefill
# and must be recycled before a client may join them.
self._ticked = False
# -- connection side (asyncio thread) ------------------------------------
def acquire(self, persona: str | None = None) -> tuple[Slot, bool] | None:
"""Claim an idle slot. Returns ``(slot, ready_now)`` or ``None`` if full.
A pristine slot (never used since boot, and claimed before the first
engine tick) still carries the default voice + persona from
``open_batch`` and is live immediately when no custom persona is
requested. Otherwise the slot is recycled and its system prompt
replayed over the next ticks (``ready_now == False``) -- including
pristine rows after ticking has started, because lockstep stepping
advances every row whether or not a client is attached.
"""
with self.lock:
slot = next((s for s in self.slots if s.phase is SlotPhase.IDLE), None)
if slot is None:
return None
slot.epoch += 1
slot.pending = np.zeros(0, dtype=np.float32)
slot.inq.clear()
slot.prefill.clear()
if not slot.used and persona is None and not self._ticked:
slot.phase = SlotPhase.LIVE
slot.used = True
return slot, True
# The engine reset itself must run BETWEEN engine steps, i.e. on the
# stepping thread -- only mark it here; tick() executes it.
slot.prefill.extend(self.engine.prefill_steps(persona))
slot.needs_recycle = True
slot.phase = SlotPhase.PREFILL
slot.used = True
return slot, False
def release(self, slot: Slot) -> None:
with self.lock:
slot.phase = SlotPhase.IDLE
slot.epoch += 1
slot.on_output = None
slot.on_ready = None
slot.inq.clear()
slot.prefill.clear()
slot.pending = np.zeros(0, dtype=np.float32)
def feed(self, slot: Slot, pcm: np.ndarray, epoch: int) -> None:
"""Buffer incoming PCM and carve it into exact engine frames."""
with self.lock:
if slot.epoch != epoch or slot.phase is SlotPhase.IDLE:
return
samples = np.ascontiguousarray(pcm, dtype=np.float32).reshape(-1)
slot.pending = np.concatenate([slot.pending, samples]) if slot.pending.size else samples
n = self.frame_size
while slot.pending.shape[0] >= n:
frame, slot.pending = slot.pending[:n], slot.pending[n:]
slot.inq.append(frame)
while len(slot.inq) > self.max_buffered_frames:
slot.inq.popleft() # realtime: drop the oldest backlog
# -- stepping side (dedicated thread) ------------------------------------
def tick(self) -> bool:
"""Advance every non-idle slot one frame. Returns False when all idle."""
with self.lock:
if all(s.phase is SlotPhase.IDLE for s in self.slots):
return False
self._ticked = True
pcm = np.zeros((self.batch_size, self.frame_size), dtype=np.float32)
prefill: dict[int, Any] = {}
emitters: list[tuple[Slot, int]] = []
for slot in self.slots:
if slot.phase is SlotPhase.LIVE:
if slot.inq:
pcm[slot.index] = slot.inq.popleft()
emitters.append((slot, slot.epoch))
elif slot.phase is SlotPhase.PREFILL:
if slot.needs_recycle:
self.engine.recycle_slot(slot.index)
slot.needs_recycle = False
while slot.prefill and slot.prefill[0].kind == "voice_end":
self.engine.voice_end(slot.index)
slot.prefill.popleft()
if slot.prefill:
prefill[slot.index] = slot.prefill.popleft()
else:
self.engine.end_prefill(slot.index)
slot.phase = SlotPhase.LIVE
emitters.append((slot, slot.epoch))
if slot.on_ready is not None:
slot.on_ready()
outs = self.engine.step_batch(pcm, prefill or None)
with self.lock:
for slot, epoch in emitters:
out = outs[slot.index]
if out is None or slot.epoch != epoch or slot.on_output is None:
continue
slot.on_output(out)
return True
def run_forever(self, stop: threading.Event, frame_seconds: float = 0.08) -> None:
"""Wall-clock-paced stepping loop (run on a dedicated thread)."""
next_t = time.monotonic()
while not stop.is_set():
did = self.tick()
if not did:
time.sleep(frame_seconds / 4)
next_t = time.monotonic()
continue
next_t += frame_seconds
delay = next_t - time.monotonic()
if delay > 0:
time.sleep(delay)
elif delay < -1.0:
# Fell far behind realtime (e.g. B too large for the GPU); resync
# rather than bursting to catch up.
logger.warning("batched tick loop %.1fs behind realtime; resyncing", -delay)
next_t = time.monotonic()
@@ -0,0 +1,539 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""PersonaPlex full-duplex server, compatible with the OFFICIAL Moshi web client.
Serves the official PersonaPlex web UI (``dist.tgz`` from ``nvidia/personaplex-7b-v1``,
the same bundle ``moshi.server`` downloads) and implements its WebSocket protocol at
``/api/chat`` -- but driven by our native-component engine instead of moshi's server.
Built on **aiohttp**, mirroring ``moshi.server`` exactly: aiohttp writes+drains each
WebSocket frame promptly, whereas uvicorn's ``websockets`` backend coalesces many small
(~80 ms Opus) frames into a send buffer, which makes browser playback choppy even when
the engine is real-time. aiohttp ships as a moshi dependency, so this adds nothing new.
Moshi protocol (binary WS messages, first byte = tag):
server -> client ``\\x00`` handshake (ready, after system prompts)
client -> server ``\\x01`` + opus bytes mic audio (Opus @ 24kHz)
server -> client ``\\x01`` + opus bytes agent audio (Opus @ 24kHz)
server -> client ``\\x02`` + utf8 inner-monologue text
Query params on connect: ``text_prompt`` (persona), ``voice_prompt`` (voice file).
Run (auto-downloads the official UI; open http://<host>:8124/):
HF_TOKEN=... CUDA_VISIBLE_DEVICES=0 python -m \\
vllm_omni.experimental.fullduplex.personaplex.serving.server --port 8124
A raw-PCM endpoint (``/v1/audio/duplex``, JSON ``open`` + float32 frames) remains for
tooling/tests that do not want an Opus dependency.
"""
from __future__ import annotations
import argparse
import asyncio
import contextlib
import functools
import json
import logging
import tarfile
import threading
from pathlib import Path
import numpy as np
import sphn
from aiohttp import WSMsgType, web
from huggingface_hub import hf_hub_download
from vllm_omni.experimental.fullduplex.personaplex.config import PersonaPlexConfig
from vllm_omni.experimental.fullduplex.personaplex.engine import PersonaPlexEngine
from vllm_omni.experimental.fullduplex.personaplex.serving.batched import BatchedSessionManager
from vllm_omni.experimental.fullduplex.personaplex.session import (
PersonaPlexServingSessionState,
PersonaPlexSession,
offer_realtime,
)
logger = logging.getLogger(__name__)
_HF_REPO = "nvidia/personaplex-7b-v1"
def _opus_encode(writer: sphn.OpusStreamWriter, pcm: np.ndarray) -> bytes:
"""Encode one PCM frame to Opus bytes, across sphn API versions.
sphn 0.2.x returns the encoded bytes straight from ``append_pcm``; the older
0.1.x buffers and requires a ``read_bytes`` drain. Handle both.
"""
out = writer.append_pcm(np.ascontiguousarray(pcm, dtype=np.float32))
if out is None and hasattr(writer, "read_bytes"):
out = writer.read_bytes()
return out or b""
def _opus_decode(reader: sphn.OpusStreamReader, data: bytes) -> np.ndarray | None:
"""Decode Opus bytes to PCM, across sphn API versions (see ``_opus_encode``)."""
out = reader.append_bytes(data)
if out is None and hasattr(reader, "read_pcm"):
out = reader.read_pcm()
if out is None or getattr(out, "shape", (0,))[-1] == 0:
return None
return np.asarray(out, dtype=np.float32)
def _official_web_dir() -> Path | None:
"""Download + extract the official PersonaPlex web client (dist.tgz)."""
try:
tgz = Path(hf_hub_download(_HF_REPO, "dist.tgz"))
dist = tgz.parent / "dist"
if not dist.exists():
with tarfile.open(tgz, "r:gz") as tar:
tar.extractall(path=tgz.parent) # noqa: S202 - trusted first-party bundle
return dist if dist.exists() else None
except Exception as exc: # network / auth / layout
logger.warning("official web client unavailable (%s); / will return an error note", exc)
return None
class DuplexServer:
"""Owns the loaded engine; serves one duplex conversation at a time.
A capacity-one lease covers the entire WebSocket lifetime on BOTH endpoints:
the engine is a single shared streaming state, so a second concurrent
connection would reset and corrupt the live conversation. Extra connections
are rejected with 1013 (try again later). Engine work (``open``/``feed``,
synchronous CUDA) runs on a worker thread so the event loop stays free for
WebSocket traffic and close handling.
"""
def __init__(self, config: PersonaPlexConfig) -> None:
self.config = config
self.engine = PersonaPlexEngine(config)
self._active = False # capacity-one session lease (event-loop confined)
self._lease_release_tasks: set[asyncio.Task] = set()
def load(self) -> None:
self.engine.load()
async def _lease(self, ws: web.WebSocketResponse) -> bool:
"""Try to claim the single-session lease; reject the connection if held."""
if self._active:
await ws.close(code=1013, message=b"another conversation is active")
return False
self._active = True
return True
async def _engine_call(self, state: PersonaPlexServingSessionState, fn, *args, **kwargs):
"""Run one synchronous engine call on a worker thread, tracked for drain.
Cancelling the awaiting task does NOT stop the worker thread, and it
marks the asyncio wrapper future cancelled (``done()``) even while the
thread keeps running, so the future itself cannot signal completion.
The worker instead sets a plain ``threading.Event`` when the call truly
finishes; :meth:`_drain_inflight` waits on that event before the lease
is released. Without this, a fast reconnect could acquire the lease and
call ``open()`` while the previous connection's final ``step()`` is
still mutating the shared engine on its thread.
"""
call = functools.partial(fn, *args, **kwargs)
finished = threading.Event()
def run_and_signal():
try:
return call()
finally:
finished.set()
state.inflight = finished
result = await asyncio.get_running_loop().run_in_executor(None, run_and_signal)
state.inflight = None # on cancellation, the event stays referenced for drain
return result
@staticmethod
async def _drain_inflight(state: PersonaPlexServingSessionState, timeout: float = 5.0) -> bool:
"""Wait out any engine call still running on a worker thread."""
finished = state.inflight
if finished is None or finished.is_set():
return True
return bool(await asyncio.to_thread(finished.wait, timeout))
async def _release_lease_when_finished(self, state: PersonaPlexServingSessionState) -> None:
finished = state.inflight
if finished is not None and not finished.is_set():
await asyncio.to_thread(finished.wait)
self._active = False
async def _release_lease_after_drain(
self,
state: PersonaPlexServingSessionState,
*,
timeout: float = 5.0,
) -> bool:
"""Release now when drained, otherwise retain the lease until completion."""
if await self._drain_inflight(state, timeout):
self._active = False
return True
logger.error(
"PersonaPlex engine cleanup exceeded %.1fs; retaining the session lease until the worker thread finishes",
timeout,
)
task = asyncio.create_task(self._release_lease_when_finished(state))
self._lease_release_tasks.add(task)
task.add_done_callback(self._lease_release_tasks.discard)
return False
# ---- official Moshi protocol (Opus over aiohttp WS) ---------------------
async def handle_chat(self, request: web.Request) -> web.WebSocketResponse:
ws = web.WebSocketResponse()
await ws.prepare(request)
if not await self._lease(ws):
return ws
state = PersonaPlexServingSessionState()
try:
persona = (request.query.get("text_prompt") or "").strip() or None
voice = (request.query.get("voice_prompt") or "").strip() or None
sr = self.engine.sample_rate
session = PersonaPlexSession(self.engine, self.config)
# System-prompt prefill is seconds of GPU work; keep it off the loop.
await self._engine_call(state, session.open, voice_prompt=voice, persona=persona)
reader = sphn.OpusStreamReader(sr)
writer = sphn.OpusStreamWriter(sr)
await ws.send_bytes(b"\x00") # ready handshake
async def recv_loop() -> None:
try:
async for msg in ws:
if msg.type == WSMsgType.BINARY:
data = msg.data
if data and data[0] == 1:
pcm = _opus_decode(reader, data[1:])
if pcm is not None:
offer_realtime(state.pcm_queue, pcm)
elif msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.ERROR):
break
finally:
state.closed = True
async def proc_loop() -> None:
# Drain decoded PCM and step the model, streaming agent Opus + text
# out. The synchronous engine step runs on a worker thread.
while not state.closed:
try:
pcm = await asyncio.wait_for(state.pcm_queue.get(), timeout=0.25)
except asyncio.TimeoutError:
continue
for frame_out in await self._engine_call(state, session.feed, pcm):
if frame_out.audio is not None:
opus = _opus_encode(writer, frame_out.audio)
if opus:
await ws.send_bytes(b"\x01" + opus)
if frame_out.text:
await ws.send_bytes(b"\x02" + frame_out.text.encode("utf8"))
tasks = [asyncio.create_task(recv_loop()), asyncio.create_task(proc_loop())]
try:
_, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for t in pending:
t.cancel()
with contextlib.suppress(asyncio.CancelledError):
await t
finally:
with contextlib.suppress(Exception):
await ws.close()
finally:
# A cancelled feed/open may still be running on its worker thread;
# wait it out BEFORE freeing the lease, or a fast reconnect races
# the shared engine (CUDA illegal access / cross-session bleed).
await self._release_lease_after_drain(state)
return ws
# ---- simple raw-PCM protocol (no Opus, for tests) ----------------------
async def handle_raw(self, request: web.Request) -> web.WebSocketResponse:
ws = web.WebSocketResponse()
await ws.prepare(request)
if not await self._lease(ws):
return ws
session = PersonaPlexSession(self.engine, self.config)
state = PersonaPlexServingSessionState()
opened = False
try:
async for msg in ws:
if msg.type == WSMsgType.TEXT:
req = json.loads(msg.data)
if req.get("type") == "open":
await self._engine_call(
state, session.open, voice_prompt=req.get("voice"), persona=req.get("persona")
)
opened = True
await ws.send_json({"type": "ready"})
elif req.get("type") == "close":
if opened:
await self._emit(ws, await self._engine_call(state, session.flush))
await ws.send_json({"type": "done"})
break
elif msg.type == WSMsgType.BINARY:
if not opened:
await self._engine_call(state, session.open)
opened = True
pcm = np.frombuffer(msg.data, dtype=np.float32)
await self._emit(ws, await self._engine_call(state, session.feed, pcm))
elif msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.ERROR):
break
finally:
# Same as handle_chat: wait out any worker-thread engine call
# before freeing the lease (see _engine_call).
await self._release_lease_after_drain(state)
with contextlib.suppress(Exception):
await ws.close()
return ws
@staticmethod
async def _emit(ws: web.WebSocketResponse, outputs: list) -> None:
for frame_out in outputs:
if frame_out.audio is not None:
await ws.send_bytes(np.ascontiguousarray(frame_out.audio, dtype=np.float32).tobytes())
if frame_out.text:
await ws.send_json({"type": "text", "text": frame_out.text})
class BatchedDuplexServer:
"""Serve ``batch_size`` concurrent duplex conversations on one engine.
Same wire protocols as :class:`DuplexServer`; each connection claims a slot
from the shared :class:`BatchedSessionManager`, whose stepping thread ticks
all live slots in lockstep at 12.5 Hz wall clock. A connection with a custom
``text_prompt`` (or reusing a previously used slot) waits while the slot's
system prompt is replayed per-row (voice embeddings + persona), then gets the
ready handshake. Per-connection custom voices are not supported in batched
mode (fixed default voice; ``voice_prompt`` is ignored with a warning).
"""
def __init__(self, config: PersonaPlexConfig) -> None:
self.config = config
self.engine = PersonaPlexEngine(config)
self.manager: BatchedSessionManager | None = None
self._stop = threading.Event()
self._thread: threading.Thread | None = None
def load(self) -> None:
self.engine.load()
self.engine.open_batch()
self.manager = BatchedSessionManager(self.engine)
self._thread = threading.Thread(
target=self.manager.run_forever, args=(self._stop,), daemon=True, name="pplex-tick"
)
self._thread.start()
def shutdown(self) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=2.0)
async def _acquire(self, ws: web.WebSocketResponse, persona: str | None):
"""Claim a slot and wait until it is live.
Returns ``PersonaPlexServingSessionState`` (slot + bounded output queue)
or ``None`` when the server is full. Exception-safe: if this coroutine
is cancelled (client gone mid-prefill) or the wait fails, the slot is
released instead of leaking in the PREFILL/LIVE phase.
"""
acquired = self.manager.acquire(persona=persona)
if acquired is None:
await ws.close(code=1013, message=b"all conversation slots busy")
return None
slot, ready_now = acquired
try:
loop = asyncio.get_running_loop()
state = PersonaPlexServingSessionState(slot=slot, epoch=slot.epoch)
ready = asyncio.Event()
with self.manager.lock:
# Bounded handoff: the tick thread produces one frame per 80 ms;
# a client that stops reading gets drop-oldest, not unbounded RAM.
slot.on_output = lambda frame_out: loop.call_soon_threadsafe(offer_realtime, state.out_queue, frame_out)
if ready_now:
ready.set()
else:
slot.on_ready = lambda: loop.call_soon_threadsafe(ready.set)
await ready.wait()
except BaseException:
self.manager.release(slot)
raise
return state
async def handle_chat(self, request: web.Request) -> web.WebSocketResponse:
ws = web.WebSocketResponse()
await ws.prepare(request)
persona = (request.query.get("text_prompt") or "").strip() or None
if (request.query.get("voice_prompt") or "").strip():
logger.warning("batched mode ignores per-connection voice_prompt (fixed default voice)")
state = await self._acquire(ws, persona)
if state is None:
return ws
try:
sr = self.engine.sample_rate
reader = sphn.OpusStreamReader(sr)
writer = sphn.OpusStreamWriter(sr)
await ws.send_bytes(b"\x00") # ready handshake
async def recv_loop() -> None:
try:
async for msg in ws:
if msg.type == WSMsgType.BINARY:
data = msg.data
if data and data[0] == 1:
pcm = _opus_decode(reader, data[1:])
if pcm is not None:
self.manager.feed(state.slot, pcm, state.epoch)
elif msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.ERROR):
break
finally:
state.closed = True
async def out_loop() -> None:
while not state.closed:
try:
frame_out = await asyncio.wait_for(state.out_queue.get(), timeout=0.25)
except asyncio.TimeoutError:
continue
if frame_out.audio is not None:
msg = _opus_encode(writer, frame_out.audio)
if msg:
await ws.send_bytes(b"\x01" + msg)
if frame_out.text:
await ws.send_bytes(b"\x02" + frame_out.text.encode("utf8"))
tasks = [asyncio.create_task(recv_loop()), asyncio.create_task(out_loop())]
_, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for t in pending:
t.cancel()
with contextlib.suppress(asyncio.CancelledError):
await t
finally:
self.manager.release(state.slot)
with contextlib.suppress(Exception):
await ws.close()
return ws
async def handle_raw(self, request: web.Request) -> web.WebSocketResponse:
ws = web.WebSocketResponse()
await ws.prepare(request)
state: PersonaPlexServingSessionState | None = None
sender: asyncio.Task | None = None
async def pump(outq: asyncio.Queue) -> None:
# Forward slot output continuously (the tick loop produces in realtime).
while True:
frame_out = await outq.get()
if frame_out.audio is not None:
await ws.send_bytes(np.ascontiguousarray(frame_out.audio, dtype=np.float32).tobytes())
if frame_out.text:
await ws.send_json({"type": "text", "text": frame_out.text})
try:
async for msg in ws:
if msg.type == WSMsgType.TEXT:
req = json.loads(msg.data)
if req.get("type") == "open":
if state is not None:
# One slot lease per connection; a second `open` would
# silently leak the first slot and its pump.
await ws.send_json({"type": "error", "error": "session already open"})
continue
state = await self._acquire(ws, (req.get("persona") or "").strip() or None)
if state is None:
return ws
sender = asyncio.create_task(pump(state.out_queue))
await ws.send_json({"type": "ready"})
elif req.get("type") == "close":
await asyncio.sleep(0.3) # let in-flight frames flush via pump
await ws.send_json({"type": "done"})
break
elif msg.type == WSMsgType.BINARY and state is not None:
self.manager.feed(state.slot, np.frombuffer(msg.data, dtype=np.float32), state.epoch)
elif msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.ERROR):
break
finally:
# Release FIRST and unconditionally: a pump that already failed (e.g.
# peer reset mid-send) re-raises on await and must not leak the slot.
if state is not None:
self.manager.release(state.slot)
if sender is not None:
sender.cancel()
try:
await sender
except asyncio.CancelledError:
pass
except Exception:
logger.debug("raw output pump ended with an error", exc_info=True)
with contextlib.suppress(Exception):
await ws.close()
return ws
def create_app(config: PersonaPlexConfig) -> web.Application:
server = BatchedDuplexServer(config) if config.batch_size > 1 else DuplexServer(config)
web_dir = _official_web_dir()
async def _startup(_app: web.Application) -> None:
logger.info("PersonaPlex duplex server: loading engine (batch_size=%d)", config.batch_size)
server.load()
logger.info("PersonaPlex duplex server: ready (official web: %s)", bool(web_dir))
async def _cleanup(_app: web.Application) -> None:
if isinstance(server, BatchedDuplexServer):
server.shutdown()
app = web.Application()
app.on_startup.append(_startup)
app.on_cleanup.append(_cleanup)
async def health(_request: web.Request) -> web.Response:
return web.json_response({"status": "ok"})
app.router.add_get("/health", health)
app.router.add_get("/api/chat", server.handle_chat)
app.router.add_get("/v1/audio/duplex", server.handle_raw)
# Serve the official PersonaPlex web client (explicit routes are matched first).
if web_dir is not None:
async def index(_request: web.Request) -> web.FileResponse:
return web.FileResponse(web_dir / "index.html")
app.router.add_get("/", index)
app.router.add_static("/", path=str(web_dir), follow_symlinks=True, name="web")
else:
async def index_missing(_request: web.Request) -> web.Response:
return web.json_response({"error": f"official web client unavailable; check access to {_HF_REPO}"})
app.router.add_get("/", index_missing)
return app
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="0.0.0.0")
ap.add_argument("--port", type=int, default=8124)
ap.add_argument("--persona", default=None)
ap.add_argument("--voice", default="NATF2.pt")
ap.add_argument(
"--batch-size",
type=int,
default=1,
help="Concurrent conversation slots on one engine (elastic batching when > 1).",
)
args = ap.parse_args()
logging.basicConfig(level=logging.INFO)
cfg = PersonaPlexConfig(
voice_prompt=args.voice,
batch_size=args.batch_size,
**({"persona": args.persona} if args.persona else {}),
)
web.run_app(create_app(cfg), host=args.host, port=args.port)
if __name__ == "__main__":
main()
@@ -0,0 +1,233 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Mapping
from copy import deepcopy
from dataclasses import dataclass, field
from pathlib import PurePath
from typing import Any
from vllm_omni.experimental.fullduplex.openai.protocol import (
DuplexCapabilities,
)
from vllm_omni.experimental.fullduplex.openai.runtime_adapter import (
ServingRuntimeConfigError,
)
from vllm_omni.experimental.fullduplex.personaplex.config import DEFAULT_PERSONA
from vllm_omni.experimental.fullduplex.personaplex.data_plane import (
PersonaPlexDataPlaneContext,
PersonaPlexDataPlaneSession,
)
from vllm_omni.experimental.fullduplex.personaplex.input import (
PersonaPlexPcmAppendBuffer,
)
EncodeAudio = Callable[[object, int, str, float | None], str | None]
_PRIVATE_RUNTIME_CONFIG_KEYS = frozenset(
{
"personaplex_prefill_slots",
"personaplex_model_path",
}
)
@dataclass(slots=True)
class PersonaPlexServingSessionState:
audio_buffer: PersonaPlexPcmAppendBuffer = field(default_factory=PersonaPlexPcmAppendBuffer)
input_since_commit: bool = False
speech_since_commit: bool = False
committed_audio_payload: dict[str, object] | None = None
committed_audio_operation_id: str | None = None
committed_audio_reserved_bytes: int = 0
deferred_response_create: bool = False
deferred_precreate_response: bool = False
data_plane_task: asyncio.Task[None] | None = None
data_plane_restart_requested: bool = False
continuation_owner_id: str | None = None
continuation_units: int = 0
pending_silence_task: asyncio.Task[bool] | None = None
pending_silence_owner_id: str | None = None
silence_continuation_scheduler: Callable[..., Awaitable[bool]] | None = None
def retain_committed_audio(
self,
payload: dict[str, object],
*,
operation_id: str | None,
reserved_bytes: int = 0,
) -> None:
self.committed_audio_payload = payload
self.committed_audio_operation_id = operation_id
self.committed_audio_reserved_bytes += max(0, int(reserved_bytes))
def clear_committed_audio(self) -> int:
reserved_bytes = self.committed_audio_reserved_bytes
self.committed_audio_payload = None
self.committed_audio_operation_id = None
self.committed_audio_reserved_bytes = 0
self.deferred_response_create = False
self.deferred_precreate_response = False
return reserved_bytes
def clear_continuation(self) -> None:
self.continuation_owner_id = None
self.continuation_units = 0
self.pending_silence_task = None
self.pending_silence_owner_id = None
class PersonaPlexServingRuntimeAdapter:
adapter_id = "personaplex"
clean_response_done_prefix = ""
interrupted_tts_prefix = ""
private_runtime_config_keys = _PRIVATE_RUNTIME_CONFIG_KEYS
def __init__(self, encode_audio: EncodeAudio) -> None:
self.session_states: dict[str, PersonaPlexServingSessionState] = {}
self.data_plane = PersonaPlexDataPlaneSession(encode_audio)
def create_session_state(self) -> PersonaPlexServingSessionState:
return PersonaPlexServingSessionState()
def session_state(self, session_id: str) -> PersonaPlexServingSessionState:
return self.session_states.setdefault(session_id, self.create_session_state())
def remove_session_state(self, session_id: str) -> None:
self.session_states.pop(session_id, None)
@staticmethod
def is_enabled(config: object) -> bool:
del config
return True
@staticmethod
def capabilities(*, max_sessions: int) -> DuplexCapabilities:
supports_multi_session = max_sessions > 1
return DuplexCapabilities(
supports_model_native_turn_policy=True,
supports_external_turn_signal=False,
supports_client_commit=False,
supports_barge_in=False,
supports_playback_ack=True,
supports_input_append=True,
supports_replace_latest_chunk=False,
supports_reencode_context=False,
supports_rollback_to_checkpoint=False,
supports_turn_commit_only=False,
supports_model_internal_state=True,
supports_stage_resumption=True,
supports_core_resumable_request=True,
supports_stage_connector_handoff=True,
supports_independent_io_streams=True,
supports_realtime_endpoint=True,
supports_multi_session=supports_multi_session,
supports_multi_session_same_replica=supports_multi_session,
supports_session_lease=True,
supports_session_resume=False,
session_admission_mode="engine_managed",
supports_audio_truncate=False,
requires_model_runner_kv=True,
requires_native_stage_role=True,
implementation_level="model_native_duplex",
adapter_patterns=["scheduler_data_plane"],
input_modes=["append_audio_chunk"],
signal_sources=["model_native", "client_event"],
stage_handoff_transport="scheduler_data_plane",
chunk_period_ms=80,
target_barge_in_latency_ms=None,
)
@staticmethod
def validate_client_extra_body(extra_body: object) -> None:
if not isinstance(extra_body, dict):
return
private = sorted(_PRIVATE_RUNTIME_CONFIG_KEYS.intersection(extra_body))
if private:
raise ServingRuntimeConfigError("PersonaPlex runtime configuration is server-owned: " + ", ".join(private))
@classmethod
async def prepare_runtime_config(
cls,
config: object,
*,
model_config: Any,
) -> dict[str, object]:
extra_body = getattr(config, "extra_body", None)
cls.validate_client_extra_body(extra_body)
model_path = getattr(model_config, "model", None)
if not isinstance(model_path, str) or not model_path:
raise ServingRuntimeConfigError("PersonaPlex model path is unavailable")
voice = cls._voice_name(getattr(config, "voice", None))
instructions = getattr(config, "instructions", None) or DEFAULT_PERSONA
from vllm_omni.experimental.fullduplex.personaplex.stage0 import (
personaplex_prefill_slots,
)
try:
prefill_slots = await asyncio.to_thread(
personaplex_prefill_slots,
model_path,
voice,
str(instructions),
)
except Exception as exc:
raise ServingRuntimeConfigError(f"PersonaPlex voice/persona prefill could not be prepared: {exc}") from exc
return {
"personaplex_model_path": model_path,
"personaplex_voice_prompt": voice,
"personaplex_persona": str(instructions),
"personaplex_prefill_slots": prefill_slots,
}
@classmethod
def runtime_config_for_update(
cls,
config: object,
current: Mapping[str, object],
) -> dict[str, object]:
cls.validate_client_extra_body(getattr(config, "extra_body", None))
runtime_config = deepcopy(dict(current))
runtime_config["personaplex_voice_prompt"] = cls._voice_name(getattr(config, "voice", None))
runtime_config["personaplex_persona"] = str(getattr(config, "instructions", None) or DEFAULT_PERSONA)
return runtime_config
@staticmethod
def data_plane_context(
*,
epoch: int,
turn_id: int,
active_response_turn_id: int | None,
active_response_id: str | None,
auto_responds: bool,
response_format: str,
speed: float | None,
modalities: tuple[str, ...],
) -> PersonaPlexDataPlaneContext:
return PersonaPlexDataPlaneContext(
epoch=epoch,
turn_id=turn_id,
active_response_turn_id=active_response_turn_id,
active_response_id=active_response_id,
auto_responds=auto_responds,
response_format=response_format,
speed=speed,
modalities=modalities,
)
@staticmethod
def _voice_name(value: object) -> str:
voice = value if isinstance(value, str) and value else "NATF2.pt"
path = PurePath(voice)
if path.name != voice or path.suffix != ".pt" or any(part == ".." for part in path.parts):
raise ServingRuntimeConfigError("PersonaPlex voice must be a bundled .pt basename")
return voice
__all__ = [
"PersonaPlexServingRuntimeAdapter",
"PersonaPlexServingSessionState",
]
@@ -0,0 +1,124 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""PersonaPlex lockstep session driver — the runnable serving primitive.
Mirrors JoyVL's ``serving/session.py``: a thin, framework-free driver that owns a
``FrameStepper`` and turns arbitrary-length PCM chunks from a client into exact
80 ms frames, stepping the model one frame at a time. This is the path that
actually runs end to end (offline driver and any WS server build on it); the
``DuplexAdapter`` is a separate demonstration of the generic framework seam.
"""
from __future__ import annotations
import asyncio
import contextlib
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
import numpy as np
from vllm_omni.experimental.fullduplex.personaplex.config import PersonaPlexConfig
from vllm_omni.experimental.fullduplex.personaplex.engine import FrameOutput, FrameStepper
if TYPE_CHECKING:
from numpy.typing import NDArray
# Realtime queue bounds: 80 ms/frame, so 50 frames = 4 s of backlog. Anything
# beyond that means the peer cannot keep up and old frames are already useless.
MAX_PENDING_INPUT = 50
MAX_PENDING_OUTPUT = 50
def offer_realtime(queue: asyncio.Queue, item: Any) -> None:
"""Enqueue with a drop-oldest policy (realtime streams must never grow unbounded)."""
while True:
try:
queue.put_nowait(item)
return
except asyncio.QueueFull:
with contextlib.suppress(asyncio.QueueEmpty):
queue.get_nowait()
@dataclass
class PersonaPlexServingSessionState:
"""Mutable per-connection serving state (the minicpmo45 ``session.py`` role).
One instance per WebSocket connection, for both server modes: the
single-session server uses ``pcm_queue`` (decoded mic PCM awaiting the
engine); the batched server uses ``out_queue`` + ``slot``/``epoch`` (engine
output awaiting send, and the claimed slot lease). ``closed`` tears both
pump loops down.
"""
pcm_queue: asyncio.Queue = field(default_factory=lambda: asyncio.Queue(maxsize=MAX_PENDING_INPUT))
out_queue: asyncio.Queue = field(default_factory=lambda: asyncio.Queue(maxsize=MAX_PENDING_OUTPUT))
closed: bool = False
slot: Any = None # batched mode: the claimed serving/batched.py Slot
epoch: int = 0 # batched mode: slot epoch at claim time (stale-guard)
# Single-session mode: completion event of the engine call currently running
# on a worker thread. Task cancellation cannot stop that thread (and marks
# the asyncio future cancelled while the thread runs on), so the server
# waits on this event before releasing its lease (DuplexServer._engine_call).
inflight: Any = None
class PersonaPlexSession:
"""Drive one PersonaPlex conversation in strict frame lockstep.
A session buffers incoming PCM and emits exactly one :class:`FrameOutput` per
completed ``frame_size`` window. Partial tails are held until enough samples
arrive (or ``flush()`` pads them out at end of stream).
"""
def __init__(self, stepper: FrameStepper, config: PersonaPlexConfig | None = None) -> None:
self.stepper = stepper
self.config = config or PersonaPlexConfig()
self.frame_size = stepper.frame_size
self._pending = np.zeros(0, dtype=np.float32)
self._opened = False
def open(self, voice_prompt: str | None = None, persona: str | None = None) -> None:
"""Inject the voice clone + persona and arm the conversation."""
self.stepper.open_session(voice_prompt=voice_prompt, persona=persona)
self._pending = np.zeros(0, dtype=np.float32)
self._opened = True
def feed(self, pcm: NDArray[np.float32]) -> list[FrameOutput]:
"""Append PCM and step every newly completed frame.
Returns one :class:`FrameOutput` per frame consumed from the buffer this
call (possibly empty if fewer than ``frame_size`` samples are buffered).
"""
if not self._opened:
raise RuntimeError("PersonaPlexSession.open() must be called before feed()")
samples = np.ascontiguousarray(pcm, dtype=np.float32).reshape(-1)
self._pending = np.concatenate([self._pending, samples]) if self._pending.size else samples
outputs: list[FrameOutput] = []
n = self.frame_size
while self._pending.shape[0] >= n:
frame, self._pending = self._pending[:n], self._pending[n:]
outputs.append(self.stepper.step(frame))
return outputs
def flush(self) -> list[FrameOutput]:
"""Zero-pad and step any buffered tail; call once at end of input."""
if self._pending.shape[0] == 0:
return []
pad = self.frame_size - self._pending.shape[0]
frame = np.concatenate([self._pending, np.zeros(pad, dtype=np.float32)])
self._pending = np.zeros(0, dtype=np.float32)
return [self.stepper.step(frame)]
def run(self, pcm: NDArray[np.float32]) -> tuple[NDArray[np.float32], str]:
"""Convenience for offline use: feed a whole utterance, return (audio, text).
Concatenates every emitted agent frame and joins the inner-monologue text.
"""
outputs = self.feed(pcm) + self.flush()
chunks = [o.audio for o in outputs if o.audio is not None]
audio = np.concatenate(chunks) if chunks else np.zeros(0, dtype=np.float32)
text = "".join(o.text for o in outputs if o.text)
return audio, text
@@ -0,0 +1,467 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import base64
import binascii
import io
import tarfile
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import numpy as np
from vllm_omni.experimental.fullduplex.personaplex.policy import (
AUDIO_SILENCE_FRAME_CNT,
SILENCE_TOKENS,
SINE_TOKENS,
ZERO_TEXT_TOKEN,
wrap_with_system_tags,
)
_FRAME_SAMPLES = 1920
@dataclass(slots=True)
class PersonaPlexStage0PreparedAppend:
input_ids: Any
inputs_embeds: Any
user_codes: Any
info_update: dict[str, Any]
prefill_applied: bool
prompt_offset: int
@dataclass(slots=True)
class PersonaPlexStage0SessionState:
session_id: str
incarnation: int
user_codes: Any | None = None
last_text_token: Any | None = None
last_agent_codes: Any | None = None
prefill_slots: int = 0
prepared_identity: tuple[int, int] | None = None
sampled_identity: tuple[int, int] | None = None
prepared: PersonaPlexStage0PreparedAppend | None = None
last_seq: int = 0
request_ids: set[str] = field(default_factory=set)
codec: Any | None = None
def _tokenizer_path(model_path: str) -> Path:
path = Path(model_path) / "tokenizer_spm_32k_3.model"
if not path.is_file():
raise FileNotFoundError(f"PersonaPlex tokenizer not found: {path}")
return path
def load_personaplex_tokenizer(model_path: str):
import sentencepiece
tokenizer = sentencepiece.SentencePieceProcessor(str(_tokenizer_path(model_path)))
return lambda text: list(tokenizer.encode(text))
def load_personaplex_voice_state(model_path: str, voice: str) -> dict[str, Any]:
import torch
root = Path(model_path)
for candidate in (root / "voices" / voice, root / voice):
if candidate.is_file():
state = torch.load(candidate, map_location="cpu", weights_only=True)
if isinstance(state, dict):
return state
raise ValueError(f"PersonaPlex voice bundle is not a mapping: {candidate}")
archive = root / "voices.tgz"
if archive.is_file():
with tarfile.open(archive, "r:gz") as tar:
member = next(
(item for item in tar.getmembers() if item.isfile() and Path(item.name).name == voice),
None,
)
if member is not None:
extracted = tar.extractfile(member)
if extracted is None:
raise FileNotFoundError(f"PersonaPlex voice prompt {voice!r} is unreadable")
state = torch.load(io.BytesIO(extracted.read()), map_location="cpu", weights_only=True)
if isinstance(state, dict):
return state
raise ValueError(f"PersonaPlex voice bundle {voice!r} is not a mapping")
raise FileNotFoundError(f"PersonaPlex bundled voice prompt {voice!r} was not found under {model_path!r}")
def personaplex_prefill_slots(model_path: str, voice: str, persona: str) -> int:
state = load_personaplex_voice_state(model_path, voice)
embeddings = state.get("embeddings")
if not hasattr(embeddings, "shape") or len(embeddings.shape) < 1:
raise ValueError(f"PersonaPlex voice prompt {voice!r} has no embeddings")
tokenizer = load_personaplex_tokenizer(model_path)
persona_tokens = tokenizer(wrap_with_system_tags(persona)) if persona else []
return int(embeddings.shape[0]) + 2 * AUDIO_SILENCE_FRAME_CNT + len(persona_tokens)
class PersonaPlexStage0DuplexRuntime:
"""Own per-session streaming Mimi encoders and first-append prefill."""
def __init__(
self,
stage_model: Any,
*,
model_path: str,
device: str,
codec: Any | None = None,
codec_factory: Callable[[], Any] | None = None,
max_sessions: int = 1,
tokenizer=None,
voice_loader=None,
) -> None:
if max_sessions <= 0:
raise ValueError("PersonaPlex Stage 0 max_sessions must be positive")
self.stage_model = stage_model
self.model_path = model_path
self.device = device
self.max_sessions = max_sessions
self._codec_factory = codec_factory
self._free_codecs: list[Any] = []
self._tokenizer = tokenizer
self._voice_loader = voice_loader
self.sessions: dict[tuple[str, int], PersonaPlexStage0SessionState] = {}
self.request_sessions: dict[str, tuple[str, int]] = {}
if codec is not None:
codec.streaming_init(1)
self._free_codecs.append(codec)
def prepare_append(
self,
duplex: dict[str, Any],
*,
prompt_len: int,
request_id: str | None = None,
) -> PersonaPlexStage0PreparedAppend:
import torch
session_id = duplex.get("session_id")
if not isinstance(session_id, str) or not session_id:
raise ValueError("PersonaPlex duplex append requires session_id")
incarnation = _coerce_non_negative_int(duplex.get("incarnation"), "incarnation")
epoch = _coerce_non_negative_int(duplex.get("epoch"), "epoch")
seq = _coerce_positive_int(duplex.get("seq"), "seq")
identity = (epoch, seq)
key = (session_id, incarnation)
state = self.sessions.get(key)
if state is None:
if len(self.sessions) >= self.max_sessions:
raise RuntimeError(f"PersonaPlex Stage 0 session capacity {self.max_sessions} is exhausted")
state = PersonaPlexStage0SessionState(
session_id=session_id,
incarnation=incarnation,
codec=self._acquire_codec(),
)
self.sessions[key] = state
if request_id:
state.request_ids.add(request_id)
self.request_sessions[request_id] = key
if state.prepared_identity == identity and state.prepared is not None:
return state.prepared
if seq <= state.last_seq:
raise ValueError(f"PersonaPlex duplex append seq must increase: last={state.last_seq}, got={seq}")
pcm = self._decode_pcm(duplex.get("payload"))
codec = state.codec
if codec is None:
raise RuntimeError("PersonaPlex Stage 0 session has no streaming encoder")
encoded = codec.encode_frame(torch.from_numpy(pcm).reshape(1, _FRAME_SAMPLES))
user_frame = encoded.detach().to(dtype=torch.long, device="cpu").reshape(1, -1)
if user_frame.shape[1] < 8:
raise RuntimeError(
f"PersonaPlex Mimi encoder returned {user_frame.shape[1]} codebooks, expected at least 8"
)
user_frame = user_frame[:, :8].contiguous()
state.user_codes = user_frame if state.user_codes is None else torch.cat([state.user_codes, user_frame], dim=0)
runtime_config = duplex.get("runtime_config")
runtime_config = dict(runtime_config) if isinstance(runtime_config, dict) else {}
first_append = state.last_seq == 0
device, dtype = self._model_device_dtype()
silence = torch.tensor(SILENCE_TOKENS, dtype=torch.long, device=device)
sine = torch.tensor(SINE_TOKENS, dtype=torch.long, device=device)
text_token = state.last_text_token
if text_token is None:
text_token = torch.tensor([ZERO_TEXT_TOKEN], dtype=torch.long, device=device)
last_agent = state.last_agent_codes if state.last_agent_codes is not None else silence
user_frame_count = state.user_codes.shape[0]
# Match the native lockstep ring: the temporal input sees the previous
# effective agent frame. User cb0 trails the frame being appended by one
# tick and user cb1..7 trail it by two ticks. Before live user frames
# fill those slots, text-prompt prefill leaves encoded sine on user rows.
user_d0 = state.user_codes[-2].to(device) if user_frame_count > 1 else sine
user_d1 = state.user_codes[-3].to(device) if user_frame_count > 2 else sine
depformer_audio_tokens = torch.cat(
[
silence,
user_frame[0, :1].to(device),
user_d0[1:8],
]
)
depformer_audio_provided = torch.tensor(
[
False,
*([first_append] * 7),
*([True] * 8),
],
dtype=torch.bool,
device=device,
)
live_embed = self.stage_model._build_frame_embed(
text_token,
last_agent,
last_agent,
device,
user_d0=user_d0,
user_d1=user_d1,
)
if first_append:
voice = runtime_config.get("personaplex_voice_prompt", "NATF2.pt")
persona = runtime_config.get("personaplex_persona", "")
if not isinstance(voice, str) or not voice:
raise ValueError("PersonaPlex runtime voice prompt is invalid")
if not isinstance(persona, str):
raise ValueError("PersonaPlex runtime persona is invalid")
voice_state = self._load_voice(voice)
voice_embeddings = voice_state.get("embeddings")
if not isinstance(voice_embeddings, torch.Tensor) or voice_embeddings.numel() == 0:
raise ValueError(f"PersonaPlex voice prompt {voice!r} has no embeddings")
voice_embeddings = voice_embeddings.reshape(-1, voice_embeddings.shape[-1]).to(
device=device,
dtype=dtype,
)
tokenizer = self._load_tokenizer()
persona_tokens = tokenizer(wrap_with_system_tags(persona)) if persona else []
prefill_tokens = torch.tensor(
[
*([ZERO_TEXT_TOKEN] * AUDIO_SILENCE_FRAME_CNT),
*persona_tokens,
*([ZERO_TEXT_TOKEN] * AUDIO_SILENCE_FRAME_CNT),
],
dtype=torch.long,
device=device,
)
token_prefill = self.stage_model._build_prefill_embed(
prefill_tokens,
0,
int(prefill_tokens.numel()),
device,
silence,
user_sine=sine,
)
prefill_embeds = torch.cat(
[
voice_embeddings,
token_prefill.to(dtype=dtype),
],
dim=0,
)
state.prefill_slots = int(prefill_embeds.shape[0])
full_embeds = torch.cat([prefill_embeds, live_embed.to(dtype=dtype)], dim=0)
else:
full_embeds = live_embed.to(dtype=dtype)
prepared_len = int(full_embeds.shape[0])
prompt_offset = int(prompt_len) - prepared_len
if prompt_offset < 0:
raise ValueError(
f"PersonaPlex scheduler prompt reservation mismatch: reserved={prompt_len}, prepared={prepared_len}"
)
input_ids = torch.zeros((prepared_len,), dtype=torch.long, device=device)
info_update = {
"pplex_user_codes": state.user_codes,
"pplex_silence_codes": silence.detach().cpu(),
"pplex_depformer_audio_tokens": depformer_audio_tokens.detach().cpu(),
"pplex_depformer_audio_provided": depformer_audio_provided.detach().cpu(),
"meta": {
"pplex_frame": state.prefill_slots + int(state.user_codes.shape[0]),
"pplex_prefill_len": state.prefill_slots,
},
"duplex": {
"stage0_prepared": True,
"prefill_applied": first_append,
"session_id": session_id,
"incarnation": incarnation,
"epoch": epoch,
"seq": seq,
},
}
prepared = PersonaPlexStage0PreparedAppend(
input_ids=input_ids,
inputs_embeds=full_embeds,
user_codes=state.user_codes,
info_update=info_update,
prefill_applied=first_append,
prompt_offset=prompt_offset,
)
state.prepared_identity = identity
state.prepared = prepared
state.last_seq = seq
return prepared
def record_sample(
self,
*,
request_id: str,
text_token: Any,
agent_codes: Any,
) -> None:
"""Commit one sampled temporal frame for the next live append."""
import torch
key = self.request_sessions.get(request_id)
if key is None:
raise KeyError(f"PersonaPlex Stage 0 request is not attached to a live session: {request_id}")
state = self.sessions.get(key)
if state is None or state.prepared_identity is None:
raise RuntimeError(f"PersonaPlex Stage 0 request has no prepared append: {request_id}")
if state.sampled_identity == state.prepared_identity:
return
text = torch.as_tensor(text_token, device=self._model_device_dtype()[0], dtype=torch.long).reshape(-1)
codes = torch.as_tensor(agent_codes, device=text.device, dtype=torch.long).reshape(-1)
if text.numel() != 1:
raise ValueError(f"PersonaPlex Stage 0 expected one sampled text token, got {text.numel()}")
if codes.numel() < 8:
raise ValueError(f"PersonaPlex Stage 0 expected at least 8 agent codes, got {codes.numel()}")
effective_codes = codes[:8].clone()
prepared = state.prepared
if prepared is None:
raise RuntimeError(f"PersonaPlex Stage 0 request has no prepared payload: {request_id}")
target = prepared.info_update.get("pplex_depformer_audio_tokens")
provided = prepared.info_update.get("pplex_depformer_audio_provided")
if not isinstance(target, torch.Tensor) or not isinstance(provided, torch.Tensor):
raise RuntimeError("PersonaPlex Stage 0 prepared payload has no depformer teacher-forcing state")
target = target.reshape(-1).to(device=effective_codes.device, dtype=torch.long)
provided = provided.reshape(-1).to(device=effective_codes.device, dtype=torch.bool)
if target.numel() < 8 or provided.numel() < 8:
raise RuntimeError("PersonaPlex Stage 0 depformer teacher-forcing state is shorter than one agent frame")
effective_codes = torch.where(provided[:8], target[:8], effective_codes)
state.last_agent_codes = effective_codes.detach()
state.last_text_token = text.detach()
state.sampled_identity = state.prepared_identity
def close_request(self, request_id: str) -> None:
key = self.request_sessions.pop(request_id, None)
if key is None:
return
state = self.sessions.get(key)
if state is None:
return
state.request_ids.discard(request_id)
if not state.request_ids:
self.close_session(*key)
def close_session(self, session_id: str, incarnation: int) -> None:
key = (session_id, incarnation)
state = self.sessions.pop(key, None)
if state is None:
return
for request_id in state.request_ids:
self.request_sessions.pop(request_id, None)
if state.codec is not None:
state.codec.reset_streaming()
self._free_codecs.append(state.codec)
state.codec = None
def _acquire_codec(self):
if self._free_codecs:
return self._free_codecs.pop()
if self._codec_factory is not None:
codec = self._codec_factory()
codec.streaming_init(1)
return codec
from vllm_omni.model_executor.models.personaplex.personaplex_mimi import (
PersonaPlexMimiCodec,
)
checkpoint = Path(self.model_path) / "tokenizer-e351c8d8-checkpoint125.safetensors"
codec = PersonaPlexMimiCodec(
checkpoint=str(checkpoint) if checkpoint.is_file() else None,
device=self.device,
)
codec.streaming_init(1)
return codec
def _load_tokenizer(self):
if self._tokenizer is None:
self._tokenizer = load_personaplex_tokenizer(self.model_path)
return self._tokenizer
def _load_voice(self, voice: str) -> dict[str, Any]:
if self._voice_loader is not None:
state = self._voice_loader(voice)
else:
state = load_personaplex_voice_state(self.model_path, voice)
if not isinstance(state, dict):
raise ValueError(f"PersonaPlex voice prompt {voice!r} is not a mapping")
return state
def _model_device_dtype(self):
import torch
try:
parameter = next(self.stage_model.parameters())
return parameter.device, parameter.dtype
except Exception:
device = getattr(self.stage_model, "device", torch.device(self.device))
dtype = getattr(self.stage_model, "dtype", torch.float32)
return torch.device(device), dtype
@staticmethod
def _decode_pcm(payload: object) -> np.ndarray:
if not isinstance(payload, dict):
raise ValueError("PersonaPlex duplex payload must be a mapping")
if payload.get("format") != "pcm_f32le" or payload.get("sample_rate_hz") != 24000:
raise ValueError("PersonaPlex Stage 0 requires 24 kHz pcm_f32le")
audio = payload.get("audio")
if not isinstance(audio, str):
raise ValueError("PersonaPlex Stage 0 requires base64 audio")
try:
raw = base64.b64decode(audio, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError("PersonaPlex Stage 0 audio is not valid base64") from exc
samples = np.frombuffer(raw, dtype="<f4")
if samples.size != _FRAME_SAMPLES:
raise ValueError(f"PersonaPlex Stage 0 requires {_FRAME_SAMPLES} samples per append")
if not np.isfinite(samples).all():
raise ValueError("PersonaPlex Stage 0 samples must be finite")
return np.ascontiguousarray(samples, dtype=np.float32).copy()
def _coerce_non_negative_int(value: object, name: str) -> int:
try:
result = int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"PersonaPlex duplex {name} must be an integer") from exc
if result < 0:
raise ValueError(f"PersonaPlex duplex {name} must be non-negative")
return result
def _coerce_positive_int(value: object, name: str) -> int:
result = _coerce_non_negative_int(value, name)
if result <= 0:
raise ValueError(f"PersonaPlex duplex {name} must be positive")
return result
__all__ = [
"PersonaPlexStage0DuplexRuntime",
"PersonaPlexStage0PreparedAppend",
"PersonaPlexStage0SessionState",
"load_personaplex_tokenizer",
"load_personaplex_voice_state",
"personaplex_prefill_slots",
]
@@ -0,0 +1,19 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""vllm-omni integration for PersonaPlex (a Moshi finetune, full-duplex S2S).
Offline/batch runs go through a two-stage audio->audio pipeline (talker ->
code2wav); real-time conversation is served over the duplex WebSocket path.
"""
from vllm_omni.model_executor.models.personaplex.configuration_personaplex import (
PersonaPlexConfig,
PersonaPlexDepformerConfig,
PersonaPlexMimiConfig,
)
__all__ = [
"PersonaPlexConfig",
"PersonaPlexDepformerConfig",
"PersonaPlexMimiConfig",
]
@@ -0,0 +1,62 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Configuration for the PersonaPlex Helium temporal transformer."""
from __future__ import annotations
from typing import Any
from transformers import PretrainedConfig
__all__ = ["HeliumConfig"]
class HeliumConfig(PretrainedConfig):
"""Minimal HF config for the Moshi temporal LM backbone.
The defaults are measured from the PersonaPlex checkpoint rather than inferred
from Moshi's higher-level kwargs.
"""
model_type = "helium"
keys_to_ignore_at_inference = ("past_key_values",)
def __init__(
self,
hidden_size: int = 4096,
num_hidden_layers: int = 32,
num_attention_heads: int = 32,
head_dim: int = 128,
num_key_value_heads: int = 32,
intermediate_size: int = 11264,
max_position_embeddings: int = 3000,
rope_theta: float = 10000.0,
rms_norm_eps: float = 1e-8,
vocab_size: int = 32000,
sliding_window: int = 3000,
hidden_act: str = "silu",
attention_bias: bool = False,
mlp_bias: bool = False,
tie_word_embeddings: bool = False,
use_cache: bool = True,
**kwargs: Any,
) -> None:
super().__init__(
tie_word_embeddings=tie_word_embeddings,
**kwargs,
)
self.hidden_size = hidden_size
self.num_hidden_layers = num_hidden_layers
self.num_attention_heads = num_attention_heads
self.head_dim = head_dim
self.num_key_value_heads = num_key_value_heads
self.intermediate_size = intermediate_size
self.max_position_embeddings = max_position_embeddings
self.rope_theta = rope_theta
self.rms_norm_eps = rms_norm_eps
self.vocab_size = vocab_size
self.sliding_window = sliding_window
self.hidden_act = hidden_act
self.attention_bias = attention_bias
self.mlp_bias = mlp_bias
self.use_cache = use_cache
@@ -0,0 +1,278 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Configuration for PersonaPlex (a Moshi finetune; 2-stage audio->audio pipeline).
PersonaPlex is a staged AR speech model composed of:
* a *temporal transformer* (a Llama-variant Helium backbone) that predicts, per
frame, the next text token and the codebook-0 audio token;
* a *depformer* (a small per-step transformer) that, conditioned on the temporal
hidden state, predicts the remaining audio codebooks;
* the Mimi neural audio codec, which turns the audio codebooks into 24 kHz PCM.
The temporal-transformer hyperparameters live in
:class:`~vllm_omni.model_executor.models.personaplex.configuration_helium.HeliumConfig`
(already used by the talker the lead is building). This module reuses it as the
``temporal_config`` sub-config so the config tree carries a single source of
truth, and adds two further sub-configs (``depformer_config`` and
``mimi_config``).
All defaults are *measured* from the PersonaPlex checkpoint, whose own
``config.json`` is empty; do not treat the Moshi higher-level kwargs as
authoritative.
"""
from __future__ import annotations
from typing import Any
from transformers.configuration_utils import PretrainedConfig
from transformers.utils import logging
from vllm_omni.model_executor.models.personaplex.configuration_helium import (
HeliumConfig,
)
logger = logging.get_logger(__name__)
__all__ = [
"PersonaPlexConfig",
"PersonaPlexDepformerConfig",
"PersonaPlexMimiConfig",
"HeliumConfig",
]
class PersonaPlexDepformerConfig(PretrainedConfig):
r"""Configuration for the PersonaPlex depformer (per-step code predictor).
The depformer is a small autoregressive transformer that runs ``dep_q``
inner steps per temporal frame, conditioned on the temporal hidden state,
to predict the audio codebooks. Only the first ``num_active_codebooks``
codebooks (``cb 0..7``) are decoded to PCM by Mimi; the remaining codebooks
up to ``dep_q`` are predicted but not vocoded.
Args:
hidden_size (`int`, *optional*, defaults to 1024):
Dimension of the depformer hidden representations.
num_hidden_layers (`int`, *optional*, defaults to 6):
Number of depformer transformer layers.
num_attention_heads (`int`, *optional*, defaults to 16):
Number of attention heads per depformer layer.
head_dim (`int`, *optional*, defaults to 64):
Per-head attention dimension (``hidden_size // num_attention_heads``).
max_position_embeddings (`int`, *optional*, defaults to 8):
The depformer context length (``ctx``): one position per inner step.
dep_q (`int`, *optional*, defaults to 16):
Number of audio codebooks the depformer predicts per frame.
num_active_codebooks (`int`, *optional*, defaults to 8):
Number of leading codebooks actually decoded to PCM by Mimi.
card (`int`, *optional*, defaults to 2048):
Per-codebook cardinality (audio vocab size).
rope_theta (`float`, *optional*, defaults to 10000.0):
The base period of the RoPE embeddings.
rms_norm_eps (`float`, *optional*, defaults to 1e-8):
The epsilon used by the fp32 RMS normalization layers.
hidden_act (`str`, *optional*, defaults to `"silu"`):
SwiGLU gate activation.
attention_bias (`bool`, *optional*, defaults to `False`):
Whether attention projections carry a bias.
mlp_bias (`bool`, *optional*, defaults to `False`):
Whether the MLP projections carry a bias.
"""
model_type = "personaplex_depformer"
keys_to_ignore_at_inference = ("past_key_values",)
def __init__(
self,
hidden_size: int = 1024,
num_hidden_layers: int = 6,
num_attention_heads: int = 16,
head_dim: int = 64,
num_key_value_heads: int = 16,
intermediate_size: int = 2816,
max_position_embeddings: int = 8,
dep_q: int = 16,
num_active_codebooks: int = 8,
card: int = 2048,
rope_theta: float = 10000.0,
rms_norm_eps: float = 1e-8,
hidden_act: str = "silu",
attention_bias: bool = False,
mlp_bias: bool = False,
tie_word_embeddings: bool = False,
use_cache: bool = True,
**kwargs: Any,
) -> None:
super().__init__(
tie_word_embeddings=tie_word_embeddings,
**kwargs,
)
self.hidden_size = hidden_size
self.num_hidden_layers = num_hidden_layers
self.num_attention_heads = num_attention_heads
self.head_dim = head_dim
self.num_key_value_heads = num_key_value_heads
self.intermediate_size = intermediate_size
self.max_position_embeddings = max_position_embeddings
self.dep_q = dep_q
self.num_active_codebooks = num_active_codebooks
self.card = card
self.rope_theta = rope_theta
self.rms_norm_eps = rms_norm_eps
self.hidden_act = hidden_act
self.attention_bias = attention_bias
self.mlp_bias = mlp_bias
self.use_cache = use_cache
class PersonaPlexMimiConfig(PretrainedConfig):
r"""Configuration for the Mimi neural audio codec used by PersonaPlex.
The Mimi weights and module live in the external ``moshi`` package; this
config only carries the scalars the vllm-omni serving layer needs to size
buffers and compute audio durations. The actual decoder is instantiated by
:class:`PersonaPlexCode2Wav` via ``moshi.models.loaders.get_mimi``.
Args:
sample_rate (`int`, *optional*, defaults to 24000):
Output PCM sample rate in Hz.
frame_rate (`float`, *optional*, defaults to 12.5):
Mimi codec frame rate in Hz (one frame every 80 ms).
samples_per_frame (`int`, *optional*, defaults to 1920):
PCM samples produced per codec frame (``sample_rate / frame_rate``).
num_codebooks (`int`, *optional*, defaults to 8):
Number of active audio codebooks decoded to PCM (``cb 0..7``).
card (`int`, *optional*, defaults to 2048):
Per-codebook cardinality.
num_channels (`int`, *optional*, defaults to 1):
Number of output audio channels (mono).
mimi_name (`str`, *optional*):
Filename of the Mimi weight checkpoint inside the model repo. When
``None``, :class:`PersonaPlexCode2Wav` falls back to
``moshi.models.loaders.MIMI_NAME``.
"""
model_type = "personaplex_mimi"
def __init__(
self,
sample_rate: int = 24000,
frame_rate: float = 12.5,
samples_per_frame: int = 1920,
num_codebooks: int = 8,
card: int = 2048,
num_channels: int = 1,
mimi_name: str | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.sample_rate = sample_rate
self.frame_rate = frame_rate
self.samples_per_frame = samples_per_frame
self.num_codebooks = num_codebooks
self.card = card
self.num_channels = num_channels
self.mimi_name = mimi_name
class PersonaPlexConfig(PretrainedConfig):
r"""Top-level configuration for ``PersonaPlexTalkerForConditionalGeneration``.
Mirrors the Qwen3-TTS config layout: a top-level config holding sub-configs
for each component. The temporal transformer config is the *text config*
that vLLM consumes (it exposes ``hidden_size`` / ``num_attention_heads`` and
drives the talker's vLLM Llama backbone).
Args:
temporal_config (`dict` or `HeliumConfig`, *optional*):
The temporal-transformer (Helium) backbone config.
depformer_config (`dict` or `PersonaPlexDepformerConfig`, *optional*):
The depformer (per-step code predictor) config.
mimi_config (`dict` or `PersonaPlexMimiConfig`, *optional*):
The Mimi codec config.
text_vocab_size (`int`, *optional*, defaults to 32000):
Text / ``lm_head`` vocabulary size.
text_embedding_rows (`int`, *optional*, defaults to 32001):
Number of rows in the text embedding table (one extra padding row).
audio_vocab_size (`int`, *optional*, defaults to 2048):
Per-codebook audio cardinality (``card``).
num_audio_codebooks (`int`, *optional*, defaults to 16):
Total number of audio codebooks (``n_q``).
mimi_name (`str`, *optional*):
Convenience mirror of ``mimi_config.mimi_name``; if set, it
overrides the value carried inside ``mimi_config``.
"""
model_type = "personaplex"
sub_configs = {
"temporal_config": HeliumConfig,
"depformer_config": PersonaPlexDepformerConfig,
"mimi_config": PersonaPlexMimiConfig,
}
def __init__(
self,
temporal_config: dict | HeliumConfig | None = None,
depformer_config: dict | PersonaPlexDepformerConfig | None = None,
mimi_config: dict | PersonaPlexMimiConfig | None = None,
text_vocab_size: int = 32000,
text_embedding_rows: int = 32001,
audio_vocab_size: int = 2048,
num_audio_codebooks: int = 16,
mimi_name: str | None = None,
**kwargs: Any,
) -> None:
if temporal_config is None:
temporal_config = {}
logger.info("temporal_config is None. Initializing Helium backbone with default values")
if depformer_config is None:
depformer_config = {}
logger.info("depformer_config is None. Initializing depformer with default values")
if mimi_config is None:
mimi_config = {}
logger.info("mimi_config is None. Initializing Mimi codec config with default values")
self.temporal_config = self._coerce(temporal_config, HeliumConfig)
self.depformer_config = self._coerce(depformer_config, PersonaPlexDepformerConfig)
self.mimi_config = self._coerce(mimi_config, PersonaPlexMimiConfig)
super().__init__(**kwargs)
# The PersonaPlex checkpoint's config.json is empty (no ``architectures``);
# supply the primary (stage-0 talker) arch so vLLM's ModelConfig resolves a
# generate-capable model. The code2wav stage arch comes from the pipeline.
if not getattr(self, "architectures", None):
self.architectures = ["PersonaPlexTalkerForConditionalGeneration"]
self.text_vocab_size = text_vocab_size
self.text_embedding_rows = text_embedding_rows
self.audio_vocab_size = audio_vocab_size
self.num_audio_codebooks = num_audio_codebooks
# ``mimi_name`` is allowed both at the top level (convenient for
# hf_overrides / a flat config.json) and inside ``mimi_config``. Keep
# them in sync, preferring an explicit top-level value.
if mimi_name is not None:
self.mimi_config.mimi_name = mimi_name
self.mimi_name = self.mimi_config.mimi_name
@staticmethod
def _coerce(value: Any, config_cls: type[PretrainedConfig]) -> PretrainedConfig:
if isinstance(value, config_cls):
return value
if isinstance(value, dict):
return config_cls(**value)
raise TypeError(f"Expected dict or {config_cls.__name__}, got {type(value).__name__}")
@property
def sample_rate(self) -> int:
"""Output PCM sample rate in Hz (delegates to the Mimi config)."""
return int(self.mimi_config.sample_rate)
def get_text_config(self, **kwargs: Any) -> PretrainedConfig:
# vLLM expects the text config to expose hidden_size / num_attention_heads.
# For PersonaPlex the temporal-transformer (Helium) config is the text
# model config that drives the talker's vLLM Llama backbone.
return self.temporal_config
@@ -0,0 +1,548 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""vLLM-native PersonaPlex Helium temporal transformer.
The module maps the Moshi temporal LM backbone onto vLLM's Llama-style decoder
components. It intentionally does not include Moshi's text/audio input
embeddings or depformer; PersonaPlex feeds temporal `inputs_embeds` directly.
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from itertools import islice
import torch
from torch import nn
from vllm.compilation.decorators import support_torch_compile
from vllm.config import CacheConfig, VllmConfig
from vllm.distributed import get_pp_group, get_tensor_model_parallel_world_size
from vllm.model_executor.layers.activation import SiluAndMul
from vllm.model_executor.layers.attention import Attention
from vllm.model_executor.layers.layernorm import RMSNorm
from vllm.model_executor.layers.linear import (
MergedColumnParallelLinear,
QKVParallelLinear,
RowParallelLinear,
)
from vllm.model_executor.layers.logits_processor import LogitsProcessor
from vllm.model_executor.layers.quantization import QuantizationConfig
from vllm.model_executor.layers.rotary_embedding import get_rope
from vllm.model_executor.layers.vocab_parallel_embedding import ParallelLMHead
from vllm.model_executor.model_loader.weight_utils import default_weight_loader
from vllm.model_executor.models.interfaces import SupportsLoRA, SupportsPP
from vllm.model_executor.models.utils import (
PPMissingLayer,
is_pp_missing_parameter,
make_empty_intermediate_tensors_factory,
make_layers,
maybe_prefix,
)
from vllm.sequence import IntermediateTensors
from vllm.v1.attention.backend import AttentionType
from vllm_omni.model_executor.models.personaplex.configuration_helium import (
HeliumConfig,
)
__all__ = ["HeliumForCausalLM", "HeliumModel"]
class HeliumRMSNorm(RMSNorm):
"""Moshi-compatible RMSNorm.
Moshi stores the parameter as `alpha` with shape `[1, 1, hidden]` and
computes variance and the alpha multiply in fp32 before casting back to the
input dtype. vLLM's parameter is named `weight`; the loader squeezes alpha.
"""
def _forward_f32(self, x: torch.Tensor) -> torch.Tensor:
output_dtype = x.dtype
x_f32 = x.float()
variance = x_f32.pow(2).mean(dim=-1, keepdim=True)
x_f32 = x_f32 * torch.rsqrt(variance + self.variance_epsilon)
if self.has_weight:
x_f32 = x_f32 * self.weight.float()
return x_f32.to(output_dtype)
def forward(
self,
x: torch.Tensor,
residual: torch.Tensor | None = None,
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
if residual is None:
return self._forward_f32(x)
residual = residual + x
return self._forward_f32(residual), residual
class HeliumMLP(nn.Module):
def __init__(
self,
hidden_size: int,
intermediate_size: int,
hidden_act: str,
quant_config: QuantizationConfig | None = None,
prefix: str = "",
) -> None:
super().__init__()
if hidden_act != "silu":
raise ValueError(f"Unsupported activation: {hidden_act}. Only silu is supported.")
self.gate_up_proj = MergedColumnParallelLinear(
input_size=hidden_size,
output_sizes=[intermediate_size] * 2,
bias=False,
quant_config=quant_config,
prefix=f"{prefix}.gate_up_proj",
)
self.down_proj = RowParallelLinear(
input_size=intermediate_size,
output_size=hidden_size,
bias=False,
quant_config=quant_config,
prefix=f"{prefix}.down_proj",
)
self.act_fn = SiluAndMul()
def forward(self, x: torch.Tensor) -> torch.Tensor:
x, _ = self.gate_up_proj(x)
x = self.act_fn(x)
x, _ = self.down_proj(x)
return x
def _get_helium_rope(config: HeliumConfig):
rope_kwargs = {
"head_size": config.head_dim,
"max_position": config.max_position_embeddings,
"is_neox_style": False,
"rope_parameters": {"base": config.rope_theta},
}
try:
return get_rope(**rope_kwargs)
except TypeError:
# vLLM 0.23 uses `rope_parameters`; this fallback is for older local
# source trees whose get_rope still takes rotary_dim/base/rope_scaling.
return get_rope(
head_size=config.head_dim,
rotary_dim=config.head_dim,
max_position=config.max_position_embeddings,
base=config.rope_theta,
is_neox_style=False,
)
class HeliumAttention(nn.Module):
def __init__(
self,
config: HeliumConfig,
hidden_size: int,
num_heads: int,
num_kv_heads: int,
cache_config: CacheConfig | None = None,
quant_config: QuantizationConfig | None = None,
prefix: str = "",
attn_type: str = AttentionType.DECODER,
) -> None:
super().__init__()
self.hidden_size = hidden_size
tp_size = get_tensor_model_parallel_world_size()
self.total_num_heads = num_heads
assert self.total_num_heads % tp_size == 0
self.num_heads = self.total_num_heads // tp_size
self.total_num_kv_heads = num_kv_heads
if self.total_num_kv_heads >= tp_size:
assert self.total_num_kv_heads % tp_size == 0
else:
assert tp_size % self.total_num_kv_heads == 0
self.num_kv_heads = max(1, self.total_num_kv_heads // tp_size)
self.head_dim = config.head_dim
self.q_size = self.num_heads * self.head_dim
self.kv_size = self.num_kv_heads * self.head_dim
self.scaling = self.head_dim**-0.5
self.qkv_proj = QKVParallelLinear(
hidden_size=hidden_size,
head_size=self.head_dim,
total_num_heads=self.total_num_heads,
total_num_kv_heads=self.total_num_kv_heads,
bias=False,
quant_config=quant_config,
prefix=f"{prefix}.qkv_proj",
)
self.o_proj = RowParallelLinear(
input_size=self.total_num_heads * self.head_dim,
output_size=hidden_size,
bias=False,
quant_config=quant_config,
prefix=f"{prefix}.o_proj",
)
self.rotary_emb = _get_helium_rope(config)
self.attn = Attention(
self.num_heads,
self.head_dim,
self.scaling,
num_kv_heads=self.num_kv_heads,
cache_config=cache_config,
quant_config=quant_config,
per_layer_sliding_window=config.sliding_window,
prefix=f"{prefix}.attn",
attn_type=attn_type,
)
def forward(
self,
positions: torch.Tensor,
hidden_states: torch.Tensor,
) -> torch.Tensor:
qkv, _ = self.qkv_proj(hidden_states)
q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1)
q, k = self.rotary_emb(positions, q, k)
attn_output = self.attn(q, k, v)
output, _ = self.o_proj(attn_output)
return output
class HeliumDecoderLayer(nn.Module):
def __init__(
self,
vllm_config: VllmConfig,
prefix: str = "",
config: HeliumConfig | None = None,
) -> None:
super().__init__()
config = config or vllm_config.model_config.hf_config
cache_config = vllm_config.cache_config
quant_config = vllm_config.quant_config
self.hidden_size = config.hidden_size
self.self_attn = HeliumAttention(
config=config,
hidden_size=self.hidden_size,
num_heads=config.num_attention_heads,
num_kv_heads=config.num_key_value_heads,
cache_config=cache_config,
quant_config=quant_config,
prefix=f"{prefix}.self_attn",
attn_type=AttentionType.DECODER,
)
self.mlp = HeliumMLP(
hidden_size=self.hidden_size,
intermediate_size=config.intermediate_size,
hidden_act=config.hidden_act,
quant_config=quant_config,
prefix=f"{prefix}.mlp",
)
self.input_layernorm = HeliumRMSNorm(
config.hidden_size,
eps=config.rms_norm_eps,
)
self.post_attention_layernorm = HeliumRMSNorm(
config.hidden_size,
eps=config.rms_norm_eps,
)
def forward(
self,
positions: torch.Tensor,
hidden_states: torch.Tensor,
residual: torch.Tensor | None,
) -> tuple[torch.Tensor, torch.Tensor]:
if residual is None:
residual = hidden_states
hidden_states = self.input_layernorm(hidden_states)
else:
hidden_states, residual = self.input_layernorm(hidden_states, residual)
hidden_states = self.self_attn(
positions=positions,
hidden_states=hidden_states,
)
hidden_states, residual = self.post_attention_layernorm(
hidden_states,
residual,
)
hidden_states = self.mlp(hidden_states)
return hidden_states, residual
def _flatten_inputs(
hidden_states: torch.Tensor,
positions: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor, tuple[int, int] | None]:
if hidden_states.dim() != 3:
return hidden_states, positions.reshape(-1), None
batch, seq_len, hidden = hidden_states.shape
flat_hidden = hidden_states.reshape(batch * seq_len, hidden)
if positions.dim() == 1:
if positions.numel() == seq_len:
flat_positions = positions.unsqueeze(0).expand(batch, seq_len).reshape(-1)
elif positions.numel() == batch * seq_len:
flat_positions = positions.reshape(-1)
else:
raise ValueError(
"1D positions must have length S or B*S when inputs_embeds is "
f"3D, got {positions.numel()} for B={batch}, S={seq_len}."
)
else:
flat_positions = positions.reshape(-1)
return flat_hidden, flat_positions, (batch, seq_len)
@support_torch_compile(
dynamic_arg_dims={
"input_ids": 0,
"positions": 0,
"intermediate_tensors": 0,
"inputs_embeds": 0,
}
)
class HeliumModel(nn.Module):
def __init__(
self,
*,
vllm_config: VllmConfig,
prefix: str = "",
layer_type: type[nn.Module] = HeliumDecoderLayer,
config: HeliumConfig | None = None,
) -> None:
super().__init__()
# ``config`` lets a composite model (e.g. the PersonaPlex talker) build the
# Helium backbone from its temporal sub-config while the engine's top-level
# hf_config is the composite PersonaPlexConfig. Defaults to the engine's.
config = config or vllm_config.model_config.hf_config
self.config = config
self.quant_config = vllm_config.quant_config
self.vocab_size = config.vocab_size
self.start_layer, self.end_layer, self.layers = make_layers(
config.num_hidden_layers,
lambda prefix: layer_type(vllm_config=vllm_config, prefix=prefix, config=config),
prefix=f"{prefix}.layers",
)
if get_pp_group().is_last_rank:
self.norm = HeliumRMSNorm(config.hidden_size, eps=config.rms_norm_eps)
else:
self.norm = PPMissingLayer()
self.make_empty_intermediate_tensors = make_empty_intermediate_tensors_factory(
["hidden_states", "residual"],
config.hidden_size,
)
def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
raise ValueError(
"Helium temporal forward requires inputs_embeds; Moshi token "
"embeddings are intentionally not part of this vLLM module."
)
def forward(
self,
input_ids: torch.Tensor | None,
positions: torch.Tensor,
intermediate_tensors: IntermediateTensors | None = None,
inputs_embeds: torch.Tensor | None = None,
) -> torch.Tensor | IntermediateTensors:
unflatten_shape: tuple[int, int] | None = None
if get_pp_group().is_first_rank:
if inputs_embeds is None:
raise ValueError("HeliumModel.forward requires inputs_embeds.")
hidden_states, positions, unflatten_shape = _flatten_inputs(
inputs_embeds,
positions,
)
residual = None
else:
assert intermediate_tensors is not None
hidden_states = intermediate_tensors["hidden_states"]
residual = intermediate_tensors["residual"]
positions = positions.reshape(-1)
for layer in islice(self.layers, self.start_layer, self.end_layer):
hidden_states, residual = layer(
positions,
hidden_states,
residual,
)
if not get_pp_group().is_last_rank:
return IntermediateTensors({"hidden_states": hidden_states, "residual": residual})
hidden_states, _ = self.norm(hidden_states, residual)
if unflatten_shape is not None:
batch, seq_len = unflatten_shape
hidden_states = hidden_states.reshape(batch, seq_len, -1)
return hidden_states
class HeliumForCausalLM(nn.Module, SupportsLoRA, SupportsPP):
packed_modules_mapping = {
"qkv_proj": ["q_proj", "k_proj", "v_proj"],
"gate_up_proj": ["gate_proj", "up_proj"],
}
embedding_modules = {
"lm_head": "output_embeddings",
}
def __init__(
self,
*,
vllm_config: VllmConfig,
prefix: str = "",
layer_type: type[nn.Module] = HeliumDecoderLayer,
) -> None:
super().__init__()
config = vllm_config.model_config.hf_config
self.config = config
self.quant_config = vllm_config.quant_config
self.model = HeliumModel(
vllm_config=vllm_config,
prefix=maybe_prefix(prefix, "model"),
layer_type=layer_type,
)
if get_pp_group().is_last_rank:
self.lm_head = ParallelLMHead(
config.vocab_size,
config.hidden_size,
quant_config=self.quant_config,
prefix=maybe_prefix(prefix, "lm_head"),
)
else:
self.lm_head = PPMissingLayer()
self.logits_processor = LogitsProcessor(config.vocab_size)
self.make_empty_intermediate_tensors = self.model.make_empty_intermediate_tensors
def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
return self.model.embed_input_ids(input_ids)
def forward(
self,
input_ids: torch.Tensor | None,
positions: torch.Tensor,
intermediate_tensors: IntermediateTensors | None = None,
inputs_embeds: torch.Tensor | None = None,
) -> torch.Tensor | IntermediateTensors:
return self.model(
input_ids=input_ids,
positions=positions,
intermediate_tensors=intermediate_tensors,
inputs_embeds=inputs_embeds,
)
def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor | None:
if hidden_states.dim() == 3:
batch, seq_len, hidden = hidden_states.shape
flat_hidden = hidden_states.reshape(batch * seq_len, hidden)
logits = self.logits_processor(self.lm_head, flat_hidden)
if logits is None:
return None
return logits.reshape(batch, seq_len, -1)
return self.logits_processor(self.lm_head, hidden_states)
def load_weights(
self,
weights: Iterable[tuple[str, torch.Tensor]] | Mapping[str, torch.Tensor],
) -> set[str]:
if isinstance(weights, Mapping):
weights = weights.items()
params_dict = dict(self.named_parameters(remove_duplicate=False))
loaded_params: set[str] = set()
for name, loaded_weight in weights:
mapped = self._load_moshi_weight(name, loaded_weight, params_dict)
loaded_params.update(mapped)
return loaded_params
def _load_moshi_weight(
self,
name: str,
loaded_weight: torch.Tensor,
params_dict: dict[str, nn.Parameter],
) -> set[str]:
if name == "out_norm.alpha":
return self._load_direct(
"model.norm.weight",
loaded_weight.squeeze(),
params_dict,
)
if name == "text_linear.weight":
return self._load_direct("lm_head.weight", loaded_weight, params_dict)
prefix = "transformer.layers."
if not name.startswith(prefix):
return set()
rest = name.removeprefix(prefix)
layer_index, _, suffix = rest.partition(".")
if not layer_index.isdigit() or not suffix:
return set()
base = f"model.layers.{layer_index}"
if suffix == "self_attn.in_proj_weight":
q_weight, k_weight, v_weight = loaded_weight.chunk(3, dim=0)
param_name = f"{base}.self_attn.qkv_proj.weight"
self._load_shard(param_name, q_weight, "q", params_dict)
self._load_shard(param_name, k_weight, "k", params_dict)
self._load_shard(param_name, v_weight, "v", params_dict)
return {param_name}
if suffix == "self_attn.out_proj.weight":
return self._load_direct(
f"{base}.self_attn.o_proj.weight",
loaded_weight,
params_dict,
)
if suffix == "gating.linear_in.weight":
gate_weight, up_weight = loaded_weight.chunk(2, dim=0)
param_name = f"{base}.mlp.gate_up_proj.weight"
self._load_shard(param_name, gate_weight, 0, params_dict)
self._load_shard(param_name, up_weight, 1, params_dict)
return {param_name}
if suffix == "gating.linear_out.weight":
return self._load_direct(
f"{base}.mlp.down_proj.weight",
loaded_weight,
params_dict,
)
if suffix == "norm1.alpha":
return self._load_direct(
f"{base}.input_layernorm.weight",
loaded_weight.squeeze(),
params_dict,
)
if suffix == "norm2.alpha":
return self._load_direct(
f"{base}.post_attention_layernorm.weight",
loaded_weight.squeeze(),
params_dict,
)
return set()
def _load_direct(
self,
name: str,
loaded_weight: torch.Tensor,
params_dict: dict[str, nn.Parameter],
) -> set[str]:
if name not in params_dict or is_pp_missing_parameter(name, self):
return set()
param = params_dict[name]
weight_loader = getattr(param, "weight_loader", default_weight_loader)
weight_loader(param, loaded_weight)
return {name}
def _load_shard(
self,
name: str,
loaded_weight: torch.Tensor,
shard_id: str | int,
params_dict: dict[str, nn.Parameter],
) -> None:
if name not in params_dict or is_pp_missing_parameter(name, self):
return
param = params_dict[name]
weight_loader = param.weight_loader
weight_loader(param, loaded_weight, shard_id)
@@ -0,0 +1,433 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Stage-1 Code2Wav model for PersonaPlex (Moshi finetune).
This is the ``LLM_GENERATION`` codec stage of the 2-stage PersonaPlex pipeline.
It consumes the per-frame audio codebooks produced by the talker (stage 0) and
the depformer (built by the lead), and turns them into 24 kHz PCM by calling the
external Mimi neural codec from the ``moshi`` package.
Layout contract (mirrors ``Qwen3TTSCode2Wav``):
* Per request, ``input_ids`` holds a flat codebook-major codec sequence
``[k * F]`` where ``k`` is the number of *active* codebooks Mimi decodes
(``num_codebooks``, i.e. the ``cb 0..7`` slice) and ``F`` is the number of
codec frames. The talker emits a 17-row token stack per frame; the input
processor (built by the lead) keeps only rows ``1:9`` (the 8 PCM-bearing audio
codebooks) and flattens them codebook-major before this stage.
* ``forward(...)`` returns an :class:`OmniOutput` whose ``multimodal_outputs``
carries ``{"model_outputs": [wav_per_request], "sr": [sr_per_request]}`` — the
exact shape ``Qwen3TTSCode2Wav`` returns, so the downstream audio packer is
unchanged.
The Mimi decoder is the transformers ``MimiModel`` (kyutai/mimi weights), not
in vLLM's safetensors loader, so they are loaded eagerly in ``load_weights``
part of the vLLM weights iterator (the codec owns its own checkpoint; see the
experimental fullduplex package for the same pattern).
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from pathlib import Path
from typing import Any
import torch
import torch.nn as nn
from vllm.config import VllmConfig
from vllm.forward_context import get_forward_context, is_forward_context_available
from vllm.logger import init_logger
from vllm_omni.model_executor.models.output_templates import OmniOutput
logger = init_logger(__name__)
_MIMI_DECODE_BATCH_FRAMES = 5
def _codec_ids_from_payload_or_input(
input_ids: torch.Tensor,
runtime_info: Mapping[str, Any] | None,
) -> torch.Tensor:
"""Prefer connector-delivered codec ids over scheduler placeholders."""
if isinstance(runtime_info, Mapping):
codes = runtime_info.get("codes")
if isinstance(codes, Mapping):
audio = codes.get("audio")
if isinstance(audio, torch.Tensor) and audio.numel() > 0:
return audio.reshape(-1).to(device=input_ids.device, dtype=torch.long)
if isinstance(audio, (list, tuple)) and audio:
return torch.as_tensor(audio, device=input_ids.device, dtype=torch.long).reshape(-1)
return input_ids.reshape(-1).to(dtype=torch.long)
class PersonaPlexCode2Wav(nn.Module):
"""Stage-1 Code2Wav model for PersonaPlex (GenerationModelRunner).
Wraps the external Mimi decoder. The vLLM generation runner only calls a
handful of methods on this module: ``embed_input_ids`` (dummy embeddings),
``compute_logits`` (none -- this stage never samples), ``forward`` (the
actual codec->PCM decode), ``make_omni_output`` (output normalization), and
``load_weights`` (eager Mimi construction).
"""
input_modalities = "audio"
requires_request_ids = True
def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
super().__init__()
self.vllm_config = vllm_config
self.model_path = vllm_config.model_config.model
self.config = vllm_config.model_config.hf_config
# Runner-facing capability flags, matching Qwen3TTSCode2Wav so the
# GenerationModelRunner drives this stage the same way.
self.have_multimodal_outputs = True
self.has_preprocess = False
self.has_postprocess = False
self.enable_update_additional_information = True
self.requires_raw_input_tokens = True
mimi_cfg = getattr(self.config, "mimi_config", None)
# Number of *active* codebooks Mimi decodes to PCM (cb 0..7).
self._num_codebooks = int(getattr(mimi_cfg, "num_codebooks", 8))
self._output_sample_rate = int(getattr(mimi_cfg, "sample_rate", 24000))
self._samples_per_frame = int(getattr(mimi_cfg, "samples_per_frame", 1920))
self._mimi_name = getattr(mimi_cfg, "mimi_name", None) or getattr(self.config, "mimi_name", None)
self._max_codec_sessions = int(getattr(vllm_config.model_config, "duplex_max_sessions", 1))
# The Mimi module is constructed in load_weights() (it owns its own
# weight format) and assigned here so vLLM's memory profiler can see it.
self.mimi: nn.Module | None = None
self._additional_mimi = nn.ModuleList()
self._mimi_device: torch.device | None = None
self._request_codes: dict[str, torch.Tensor] = {}
self._request_codec_slots: dict[str, int] = {}
# ------------------------------------------------------------------
# Runner-facing no-op / placeholder hooks (mirror Qwen3TTSCode2Wav).
# ------------------------------------------------------------------
def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor:
# This stage ignores token embeddings; keep a stable dummy embedding so
# the vLLM runner has a valid hidden-state placeholder.
if input_ids.numel() == 0:
return torch.empty((0, 1), device=input_ids.device, dtype=torch.float32)
return torch.zeros((input_ids.shape[0], 1), device=input_ids.device, dtype=torch.float32)
def compute_logits(
self,
hidden_states: torch.Tensor | OmniOutput,
sampling_metadata: Any = None,
) -> None:
# Code2Wav never samples; the runner short-circuits when logits are None.
return None
def get_dummy_runtime_additional_information(self, num_reqs: int) -> list[dict[str, object]]:
return [{"meta": {"personaplex_dummy_profile": True}} for _ in range(num_reqs)]
def _split_request_ids(
self,
ids: torch.Tensor,
seq_token_counts: list[int] | None = None,
) -> list[torch.Tensor]:
"""Split concatenated ``input_ids`` into per-request segments.
Uses ``seq_token_counts`` (injected by the runner via model_kwargs) when
available, falling back to forward-context ``ubatch_slices`` when
micro-batching is active. Returns ``[ids]`` for single-request batches.
Mirrors ``Qwen3TTSCode2Wav._split_request_ids``.
"""
if seq_token_counts is not None and len(seq_token_counts) > 1:
boundaries = [0]
for count in seq_token_counts:
boundaries.append(boundaries[-1] + count)
n = ids.numel()
return [ids[boundaries[i] : min(boundaries[i + 1], n)] for i in range(len(seq_token_counts))]
if is_forward_context_available():
slices = get_forward_context().ubatch_slices
if slices is not None and len(slices) > 1 and not any(hasattr(s, "token_slice") for s in slices):
boundaries = [0]
for s in slices:
boundaries.append(boundaries[-1] + s)
return [ids[boundaries[i] : boundaries[i + 1]] for i in range(len(boundaries) - 1)]
return [ids]
# ------------------------------------------------------------------
# Decode.
# ------------------------------------------------------------------
@torch.no_grad()
def forward(
self,
input_ids: torch.Tensor | None = None,
positions: torch.Tensor | None = None,
intermediate_tensors: Any = None,
inputs_embeds: torch.Tensor | None = None,
runtime_additional_information: list[dict[str, Any]] | None = None,
**kwargs: Any,
) -> OmniOutput:
"""Decode flat codebook-major codec ids into PCM via Mimi.
``input_ids`` per request is ``[k * F]`` (codebook-major), where ``k``
is ``self._num_codebooks``. The connector may send either a new delta
chunk or the cumulative prefix of a resumable request. Request-local
code history identifies the new suffix, and the streaming Mimi decoder
consumes each new frame exactly once.
"""
sr_val = int(self._output_sample_rate)
sr_tensor = torch.tensor(sr_val, dtype=torch.int32)
empty = torch.zeros((0,), dtype=torch.float32)
if input_ids is None or input_ids.numel() == 0:
return OmniOutput(
text_hidden_states=None,
multimodal_outputs={"model_outputs": [empty], "sr": [sr_tensor]},
)
ids = input_ids.reshape(-1).to(dtype=torch.long)
request_ids_list = self._split_request_ids(ids, kwargs.get("seq_token_counts"))
num_req = len(request_ids_list)
state_ids = self._resolve_request_ids(
num_req,
kwargs.get("request_ids"),
runtime_additional_information,
)
if self.mimi is None:
raise RuntimeError("PersonaPlexCode2Wav.forward called before Mimi was loaded in load_weights().")
k = int(self._num_codebooks)
device = self._mimi_device or ids.device
audios: list[torch.Tensor] = [empty] * num_req
srs = [sr_tensor] * num_req
for i, req_ids in enumerate(request_ids_list):
runtime_info = (
runtime_additional_information[i]
if runtime_additional_information is not None and i < len(runtime_additional_information)
else None
)
meta = runtime_info.get("meta") if isinstance(runtime_info, Mapping) else None
if isinstance(meta, Mapping) and meta.get("personaplex_dummy_profile") is True:
continue
flat = _codec_ids_from_payload_or_input(req_ids, runtime_info)
n = int(flat.numel())
if n == 0 or n % k != 0:
if n > 0:
logger.warning(
"PersonaPlex Code2Wav input_ids length %d not divisible by "
"num_codebooks %d; skipping malformed request.",
n,
k,
)
continue
frames = n // k
codes_kf = flat.reshape(k, frames)
state_id = state_ids[i]
delta_kf = self._new_code_suffix(state_id, codes_kf)
if delta_kf.shape[1] == 0:
continue
wav = self._decode_streaming_frames(state_id, delta_kf.to(device=device))
if wav.numel() > 0:
audios[i] = wav.to(dtype=torch.float32).reshape(-1)
return OmniOutput(
text_hidden_states=None,
multimodal_outputs={"model_outputs": audios, "sr": srs},
)
@staticmethod
def _resolve_request_ids(
count: int,
request_ids: object,
runtime_additional_information: list[dict[str, Any]] | None,
) -> list[str | None]:
if isinstance(request_ids, list | tuple):
if len(request_ids) != count:
raise ValueError(
"PersonaPlex Code2Wav request id count does not match inputs: "
f"request_ids={len(request_ids)}, inputs={count}"
)
return [str(request_id) for request_id in request_ids]
infos = runtime_additional_information or []
resolved: list[str | None] = []
for index in range(count):
info = infos[index] if index < len(infos) else None
request_id = None
if isinstance(info, Mapping):
request_id = info.get("request_id")
if request_id is None:
meta = info.get("meta")
if isinstance(meta, Mapping):
request_id = meta.get("request_id")
resolved.append(str(request_id) if request_id is not None else None)
return resolved
def _new_code_suffix(self, request_id: str | None, codes_kf: torch.Tensor) -> torch.Tensor:
if request_id is None:
return codes_kf
incoming = codes_kf.detach().to(device="cpu", dtype=torch.long)
previous = self._request_codes.get(request_id)
if (
previous is not None
and incoming.shape[1] >= previous.shape[1]
and torch.equal(incoming[:, : previous.shape[1]], previous)
):
delta = incoming[:, previous.shape[1] :]
self._request_codes[request_id] = incoming
return delta
self._request_codes[request_id] = incoming if previous is None else torch.cat([previous, incoming], dim=1)
return incoming
def _decode_streaming_frames(self, request_id: str | None, codes_kf: torch.Tensor) -> torch.Tensor:
codec, ephemeral = self._codec_for_request(request_id)
if not hasattr(codec, "decode_frame"):
raise RuntimeError("PersonaPlex Code2Wav requires a streaming Mimi decoder")
decode_frames = getattr(codec, "decode_frames", None)
chunks: list[torch.Tensor] = []
for start in range(0, codes_kf.shape[1], _MIMI_DECODE_BATCH_FRAMES):
frame_batch = codes_kf[:, start : start + _MIMI_DECODE_BATCH_FRAMES]
if frame_batch.shape[1] > 1 and callable(decode_frames):
chunks.append(self._flatten_wav(decode_frames(frame_batch.unsqueeze(0))))
else:
chunks.extend(
self._flatten_wav(codec.decode_frame(frame_batch[:, frame].unsqueeze(0)))
for frame in range(frame_batch.shape[1])
)
wav = torch.cat(chunks, dim=0) if chunks else codes_kf.new_empty(0, dtype=torch.float32)
if ephemeral:
codec.reset_streaming()
return wav
def _set_mimi_codecs(self, codecs: list[nn.Module]) -> None:
if not codecs:
raise ValueError("PersonaPlex Code2Wav requires at least one Mimi decoder")
self.mimi = codecs[0]
self._additional_mimi = nn.ModuleList(codecs[1:])
self._request_codec_slots.clear()
def _mimi_codecs(self) -> list[nn.Module]:
if self.mimi is None:
return []
return [self.mimi, *self._additional_mimi]
def _codec_for_request(self, request_id: str | None) -> tuple[nn.Module, bool]:
codecs = self._mimi_codecs()
if not codecs:
raise RuntimeError("PersonaPlexCode2Wav.forward called before Mimi was loaded in load_weights().")
occupied = set(self._request_codec_slots.values())
if request_id is not None:
slot = self._request_codec_slots.get(request_id)
if slot is None:
slot = next((index for index in range(len(codecs)) if index not in occupied), None)
if slot is None:
raise RuntimeError(f"PersonaPlex Code2Wav decoder capacity {len(codecs)} is exhausted")
codecs[slot].streaming_init(1)
self._request_codec_slots[request_id] = slot
return codecs[slot], False
slot = next((index for index in range(len(codecs)) if index not in occupied), None)
if slot is None:
raise RuntimeError(f"PersonaPlex Code2Wav decoder capacity {len(codecs)} is exhausted")
codecs[slot].streaming_init(1)
return codecs[slot], True
def on_requests_finished(self, finished_req_ids: set[str] | list[str]) -> None:
for request_id in finished_req_ids:
state_id = str(request_id)
self._request_codes.pop(state_id, None)
slot = self._request_codec_slots.pop(state_id, None)
codecs = self._mimi_codecs()
if slot is not None and slot < len(codecs):
codecs[slot].reset_streaming()
@staticmethod
def _flatten_wav(wav: torch.Tensor) -> torch.Tensor:
"""Normalize Mimi's decode output to a 1D PCM tensor.
Mimi may return ``[B, samples]`` or ``[B, C, samples]`` depending on the
moshi version; PersonaPlex is mono (``C == 1``), so collapse to 1D.
"""
if wav.dim() == 3:
# [B, C, T] -> take batch 0, channel 0.
return wav[0, 0]
if wav.dim() == 2:
# [B, T] -> batch 0.
return wav[0]
return wav.reshape(-1)
def make_omni_output(self, model_outputs: torch.Tensor | OmniOutput | tuple, **kwargs: Any) -> OmniOutput:
if isinstance(model_outputs, OmniOutput):
return model_outputs
if isinstance(model_outputs, tuple) and len(model_outputs) == len(OmniOutput._fields):
return OmniOutput(*model_outputs)
if not (isinstance(model_outputs, tuple) and len(model_outputs) == 2):
raise TypeError(
"PersonaPlexCode2Wav expected OmniOutput, OmniOutput tuple, "
f"or (audio_tensor, sr) outputs, got {type(model_outputs)}"
)
audio_tensor, sr = model_outputs
return OmniOutput(
text_hidden_states=None,
multimodal_outputs={
"model_outputs": audio_tensor,
"sr": sr,
},
)
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
"""Construct and load the external Mimi decoder.
The primary vLLM weights iterator carries no Code2Wav parameters (Mimi
owns its own checkpoint format), so it is drained and the Mimi module is
built from the moshi package's loader.
"""
# Drain the primary iterator so callers don't hang on an unconsumed
# generator; none of these weights belong to this stage.
for _ in weights:
pass
device = self.vllm_config.device_config.device
from vllm_omni.model_executor.models.personaplex.personaplex_mimi import (
PersonaPlexMimiCodec,
)
checkpoint = Path(self.model_path) / (self._mimi_name or "tokenizer-e351c8d8-checkpoint125.safetensors")
codecs = [
PersonaPlexMimiCodec(
checkpoint=str(checkpoint) if checkpoint.is_file() else None,
device=str(device),
).eval()
for _ in range(self._max_codec_sessions)
]
self._set_mimi_codecs(codecs)
self._mimi_device = torch.device(str(device))
reported_sr = getattr(codecs[0].model.config, "sampling_rate", None)
if reported_sr is not None:
self._output_sample_rate = int(reported_sr)
bytes_per_mib = 1024**2
per_slot_weight_mib = (
sum(parameter.numel() * parameter.element_size() for parameter in codecs[0].parameters()) / bytes_per_mib
)
total_weight_mib = (
sum(parameter.numel() * parameter.element_size() for codec in codecs for parameter in codec.parameters())
/ bytes_per_mib
)
logger.info(
"PersonaPlex Code2Wav loaded %d Mimi stream(s): "
"per_slot_weight_mib=%.2f total_weight_mib=%.2f "
"num_codebooks=%d sample_rate=%d samples_per_frame=%d",
len(codecs),
per_slot_weight_mib,
total_weight_mib,
self._num_codebooks,
self._output_sample_rate,
self._samples_per_frame,
)
return {name for name, _ in self.named_parameters() if name.startswith(("mimi.", "_additional_mimi."))}
@@ -0,0 +1,366 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""PersonaPlex depformer: the per-step audio code predictor.
The depformer is the small autoregressive head that, conditioned on one temporal
hidden state, predicts the ``dep_q`` audio codebooks of a single Mimi frame. It
is a faithful eager port of Moshi's depformer (``moshi.models.lm`` +
``moshi.modules.transformer``), kept deliberately outside vLLM's paged-attention
engine: its attention KV is reset every frame and only spans the ``dep_q`` inner
steps, so paging buys nothing here. This mirrors how the existing TTS code
predictors live alongside, not inside, the AR talker.
Faithful-port details (all measured from ``nvidia/personaplex-7b-v1``):
* **Per-step weights** (``weights_per_step``): each inner step ``t`` uses its own
attention in/out projection and its own gating MLP, selected by index (Moshi's
``multi_linear``). There are ``dep_q`` weight sets per layer.
* **No positional embedding** (``depformer_pos_emb="none"``): no RoPE, no sin.
* **Causal attention over all inner steps**: Moshi forces the depformer's
``context`` to None, so each step attends to every prior inner step (KV
capacity == ``weights_per_step``); the KV cache is rebuilt every temporal frame.
* **fp32 RMSNorm** (``rms_norm_f32``, eps 1e-8) with the weight stored as
``alpha`` of shape ``[1, 1, dim]``.
* **SiLU gating** MLP (Moshi ``ActivationGating``): ``linear_in`` projects to
``2 * hidden``; the halves are combined as ``silu(a) * b`` then ``linear_out``.
* **ScaledEmbedding** with ``zero_idx = -1`` (the special token maps to the zero
vector); audio table is ``card + 1`` rows, text table ``text_card + 1``.
The checkpoint ships ``dep_q = 8`` weight sets; the production loader (Moshi's
``get_moshi_lm``) overrides ``dep_q = 16`` and fills sets 8..15 by copying 0..7.
:meth:`PersonaPlexDepformer.load_weights` replicates that expansion so the module
loads directly from the raw ``model.safetensors`` as well as from an
already-expanded Moshi instance.
"""
from __future__ import annotations
from collections.abc import Callable, Iterable, Mapping
import torch
import torch.nn.functional as F
from torch import nn
from vllm_omni.model_executor.models.personaplex.configuration_personaplex import (
PersonaPlexDepformerConfig,
)
__all__ = ["PersonaPlexDepformer"]
# Moshi's special "no token / zero embedding" sentinel (``LMModel.zero_token_id``).
_ZERO_IDX = -1
# A builder takes the source state dict and returns the tensor for one target
# param, or None when the source lacks it.
_Builder = Callable[[dict], "torch.Tensor | None"]
def _rms_norm_f32(x: torch.Tensor, alpha: torch.Tensor, eps: float) -> torch.Tensor:
"""Moshi ``_rms_norm`` with the fp32 accumulation path.
Variance and the alpha multiply run in float32 before casting back, matching
``moshi.modules.transformer._rms_norm`` exactly (``dtype=torch.float``).
"""
x_dtype = x.dtype
x_f32 = x.float()
var = eps + x_f32.pow(2).mean(dim=-1, keepdim=True)
y = x_f32 * (alpha.float() * torch.rsqrt(var))
return y.to(x_dtype)
class _ScaledEmbedding(nn.Embedding):
"""``nn.Embedding`` matching Moshi's ``ScaledEmbedding`` (no norm, zero_idx).
Inputs equal to ``zero_idx`` produce the zero vector; all other inputs are
clamped to ``>= 0`` before the lookup. ``norm_emb`` is False for PersonaPlex,
so no embedding norm is applied.
"""
def __init__(self, num_embeddings: int, embedding_dim: int, zero_idx: int = _ZERO_IDX) -> None:
super().__init__(num_embeddings, embedding_dim)
self.zero_idx = zero_idx
def forward(self, idx: torch.Tensor) -> torch.Tensor: # type: ignore[override]
is_zero = idx == self.zero_idx
y = super().forward(idx.clamp(min=0))
zero = torch.zeros(1, dtype=y.dtype, device=y.device)
return torch.where(is_zero[..., None], zero, y)
class _DepformerLayer(nn.Module):
"""One depformer transformer layer with per-step attention + gating.
Attention and gating weights carry a leading ``dep_q`` axis; the active inner
step selects the slice (Moshi ``multi_linear`` / ``gating[t]``). KV is supplied
per call so the caller controls the per-frame reset and the sliding window.
"""
def __init__(self, config: PersonaPlexDepformerConfig) -> None:
super().__init__()
dim = config.hidden_size
self.dim = dim
self.num_heads = config.num_attention_heads
self.head_dim = config.head_dim
self.dep_q = config.dep_q
self.eps = config.rms_norm_eps
# Per-step fused QKV and output projections, stored exactly as Moshi does:
# ``in_proj_weight`` is [dep_q * 3*dim, dim]; ``out_proj`` is [dep_q*dim, dim].
self.in_proj_weight = nn.Parameter(torch.empty(self.dep_q * 3 * dim, dim))
self.out_proj_weight = nn.Parameter(torch.empty(self.dep_q * dim, dim))
# fp32 RMSNorm weights (Moshi ``alpha``, shape [1, 1, dim]).
self.norm1_alpha = nn.Parameter(torch.ones(1, 1, dim))
self.norm2_alpha = nn.Parameter(torch.ones(1, 1, dim))
# Per-step SiLU gating MLP. ``linear_in`` -> [2*hidden, dim]; ``linear_out`` -> [dim, hidden].
hidden = config.intermediate_size
self.gating_in = nn.Parameter(torch.empty(self.dep_q, 2 * hidden, dim))
self.gating_out = nn.Parameter(torch.empty(self.dep_q, dim, hidden))
def _in_proj(self, step: int) -> torch.Tensor:
return self.in_proj_weight.view(self.dep_q, 3 * self.dim, self.dim)[step]
def _out_proj(self, step: int) -> torch.Tensor:
return self.out_proj_weight.view(self.dep_q, self.dim, self.dim)[step]
def forward(
self,
x: torch.Tensor,
step: int,
kv: dict[str, torch.Tensor | None],
) -> torch.Tensor:
"""Run inner step ``step`` for a ``[B, 1, dim]`` input, updating ``kv`` in place."""
# --- self-attention (per-step weights, causal sliding window, no RoPE) ---
residual = x
h = _rms_norm_f32(x, self.norm1_alpha, self.eps).squeeze(1) # [B, dim]
qkv = F.linear(h, self._in_proj(step)) # [B, 3*dim]
q, k, v = qkv.split(self.dim, dim=-1)
b = q.shape[0]
q = q.view(b, self.num_heads, 1, self.head_dim)
k = k.view(b, self.num_heads, 1, self.head_dim)
v = v.view(b, self.num_heads, 1, self.head_dim)
if kv["k"] is None:
k_hist, v_hist = k, v
else:
k_hist = torch.cat([kv["k"], k], dim=2)
v_hist = torch.cat([kv["v"], v], dim=2)
kv["k"], kv["v"] = k_hist, v_hist
# Moshi forces the depformer's ``context`` to None (lm.py: kwargs_dep[
# "context"] = None), so attention spans ALL prior inner steps -- the KV
# capacity is ``weights_per_step`` (== dep_q), never windowed. The query is
# the newest step and every key is causal w.r.t. it, so no mask is needed.
attn = F.scaled_dot_product_attention(q, k_hist, v_hist) # [B, H, 1, Dh]
attn = attn.reshape(b, self.dim)
out = F.linear(attn, self._out_proj(step)).unsqueeze(1) # [B, 1, dim]
x = residual + out
# --- feed-forward (per-step SiLU gating) ---
residual = x
h = _rms_norm_f32(x, self.norm2_alpha, self.eps).squeeze(1) # [B, dim]
gated = F.linear(h, self.gating_in[step]) # [B, 2*hidden]
a, b_half = gated.chunk(2, dim=-1)
update = F.linear(F.silu(a) * b_half, self.gating_out[step]).unsqueeze(1)
return residual + update
class PersonaPlexDepformer(nn.Module):
"""Moshi depformer as a standalone per-frame code predictor.
Call :meth:`forward` once per temporal frame with the frame's text token and
temporal hidden state; it runs ``dep_q`` inner AR steps (greedy by default,
matching Moshi's ``use_sampling=False``) and returns the ``dep_q`` audio codes.
"""
def __init__(
self,
config: PersonaPlexDepformerConfig,
temporal_hidden_size: int,
text_card: int = 32000,
) -> None:
super().__init__()
self.config = config
self.dep_q = config.dep_q
self.card = config.card
dim = config.hidden_size
# Per-codebook projection of the temporal hidden state (``depformer_multi_linear``).
self.depformer_in = nn.ModuleList([nn.Linear(temporal_hidden_size, dim, bias=False) for _ in range(self.dep_q)])
# Step-0 conditions on the text token; steps 1..dep_q-1 on the previous audio code.
self.depformer_text_emb = _ScaledEmbedding(text_card + 1, dim)
self.depformer_emb = nn.ModuleList([_ScaledEmbedding(self.card + 1, dim) for _ in range(self.dep_q - 1)])
self.layers = nn.ModuleList([_DepformerLayer(config) for _ in range(config.num_hidden_layers)])
# Per-codebook output heads (Moshi ``linears``).
self.linears = nn.ModuleList([nn.Linear(dim, self.card, bias=False) for _ in range(self.dep_q)])
@torch.inference_mode()
def forward(
self,
text_token: torch.Tensor,
transformer_out: torch.Tensor,
audio_tokens: torch.Tensor | None = None,
audio_provided: torch.Tensor | None = None,
return_logits: bool = False,
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
"""Predict the ``dep_q`` audio codes for one frame.
Args:
text_token: ``[B]`` sampled text token for the frame.
transformer_out: ``[B, 1, temporal_hidden]`` temporal hidden state.
audio_tokens: optional ``[B, dep_q]`` teacher-forcing codes.
audio_provided: optional ``[B, dep_q]`` bool mask; where True the
corresponding ``audio_tokens`` entry overrides the sampled code as
the next-step input (partial teacher forcing, as in Moshi).
return_logits: also return the per-step logits ``[B, dep_q, card]``.
Returns:
``[B, dep_q]`` long tensor of sampled (greedy) audio codes, plus the
per-step logits when ``return_logits`` is set.
"""
if transformer_out.dim() != 3 or transformer_out.shape[1] != 1:
raise ValueError(f"transformer_out must be [B, 1, H]; got {tuple(transformer_out.shape)}")
prev = text_token
kv = [{"k": None, "v": None} for _ in self.layers]
codes: list[torch.Tensor] = []
logits_all: list[torch.Tensor] = []
for step in range(self.dep_q):
x = self.depformer_in[step](transformer_out) # [B, 1, dim]
if step == 0:
cond = self.depformer_text_emb(prev.unsqueeze(1))
else:
cond = self.depformer_emb[step - 1](prev.unsqueeze(1))
x = x + cond
for li, layer in enumerate(self.layers):
x = layer(x, step, kv[li])
logits = self.linears[step](x).squeeze(1) # [B, card]
sampled = logits.float().argmax(dim=-1) # greedy [B]
codes.append(sampled)
if return_logits:
logits_all.append(logits.float())
if audio_provided is not None and audio_tokens is not None:
prev = torch.where(audio_provided[:, step], audio_tokens[:, step], sampled)
else:
prev = sampled
stacked = torch.stack(codes, dim=1) # [B, dep_q]
if return_logits:
return stacked, torch.stack(logits_all, dim=1) # [B, dep_q, card]
return stacked
# ------------------------------------------------------------------
# Weight loading
# ------------------------------------------------------------------
def load_weights(
self,
weights: Iterable[tuple[str, torch.Tensor]] | Mapping[str, torch.Tensor],
) -> set[str]:
"""Load Moshi depformer weights, expanding ``dep_q`` 8 -> 16 if needed.
Accepts the raw checkpoint names (``depformer.*``, ``depformer_in.N``,
``depformer_emb.N``, ``depformer_text_emb``, ``linears.N``). Per-step
modules present only for steps 0..7 are copied to 8..15, and the fused
attention projections are tiled, exactly as Moshi's ``get_moshi_lm`` does.
"""
src = dict(weights.items() if isinstance(weights, Mapping) else weights)
src = {k: v for k, v in src.items() if self._is_depformer_key(k)}
params = dict(self.named_parameters())
loaded: set[str] = set()
for tgt, build in self._weight_plan().items():
tensor = build(src)
if tensor is None:
continue
param = params[tgt]
if tensor.shape != param.shape:
raise ValueError(
f"shape mismatch for {tgt}: checkpoint {tuple(tensor.shape)} vs param {tuple(param.shape)}"
)
param.data.copy_(tensor.to(param.dtype))
loaded.add(tgt)
return loaded
@staticmethod
def _is_depformer_key(name: str) -> bool:
return name.startswith(("depformer.", "depformer_in.", "depformer_emb.", "depformer_text_emb", "linears."))
def _weight_plan(self) -> dict[str, _Builder]:
"""Map each of this module's params to a builder over the source state dict."""
dim = self.config.hidden_size
def step_or_copy(prefix: str, step: int, suffix: str = "weight") -> _Builder:
# cb 8..15 fall back to cb (step-8) when absent (Moshi copy_missing_weights).
def build(src: dict[str, torch.Tensor]) -> torch.Tensor | None:
key = f"{prefix}.{step}.{suffix}"
if key in src:
return src[key]
if step >= 8:
return src.get(f"{prefix}.{step - 8}.{suffix}")
return None
return build
def expand_attn(layer: int, kind: str, per_step_rows: int) -> _Builder:
# ``in_proj_weight`` [steps*per_step_rows, dim] / ``out_proj.weight`` likewise;
# tile an 8-step checkpoint block up to dep_q (Moshi concat-copy expansion).
def build(src: dict[str, torch.Tensor]) -> torch.Tensor | None:
key = f"depformer.layers.{layer}.self_attn.{kind}"
t = src.get(key)
if t is None:
return None
steps = t.shape[0] // per_step_rows
if steps < self.dep_q:
reps = (self.dep_q + steps - 1) // steps
t = t.repeat(reps, 1)[: self.dep_q * per_step_rows]
return t
return build
plan: dict[str, _Builder] = {}
# depformer_in / linears / depformer_emb / text_emb
for step in range(self.dep_q):
plan[f"depformer_in.{step}.weight"] = step_or_copy("depformer_in", step)
plan[f"linears.{step}.weight"] = step_or_copy("linears", step)
plan["depformer_text_emb.weight"] = lambda src: src.get("depformer_text_emb.weight")
for step in range(self.dep_q - 1):
plan[f"depformer_emb.{step}.weight"] = step_or_copy("depformer_emb", step)
# per-layer attention + norms + gating
for layer in range(self.config.num_hidden_layers):
lp = f"depformer.layers.{layer}"
plan[f"layers.{layer}.in_proj_weight"] = expand_attn(layer, "in_proj_weight", 3 * dim)
plan[f"layers.{layer}.out_proj_weight"] = expand_attn(layer, "out_proj.weight", dim)
plan[f"layers.{layer}.norm1_alpha"] = self._reshape_alpha(f"{lp}.norm1.alpha")
plan[f"layers.{layer}.norm2_alpha"] = self._reshape_alpha(f"{lp}.norm2.alpha")
plan[f"layers.{layer}.gating_in"] = self._stack_gating(lp, "linear_in")
plan[f"layers.{layer}.gating_out"] = self._stack_gating(lp, "linear_out")
return plan
@staticmethod
def _reshape_alpha(key: str) -> _Builder:
def build(src: dict[str, torch.Tensor]) -> torch.Tensor | None:
t = src.get(key)
return None if t is None else t.reshape(1, 1, -1)
return build
def _stack_gating(self, layer_prefix: str, which: str) -> _Builder:
"""Stack the dep_q per-step gating weights into one [dep_q, ...] tensor.
Steps 8..15 fall back to step (n-8) when the checkpoint only ships 8
(Moshi ``copy_missing_weights``).
"""
def build(src: dict[str, torch.Tensor]) -> torch.Tensor | None:
slabs: list[torch.Tensor] = []
for step in range(self.dep_q):
key = f"{layer_prefix}.gating.{step}.{which}.weight"
if key not in src and step >= 8:
key = f"{layer_prefix}.gating.{step - 8}.{which}.weight"
t = src.get(key)
if t is None:
return None
slabs.append(t)
return torch.stack(slabs, dim=0)
return build
@@ -0,0 +1,90 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""PersonaPlex input embeddings (Moshi ``embed_codes``).
The temporal transformer (:class:`HeliumForCausalLM`) consumes precomputed
``inputs_embeds`` rather than token ids. Those embeddings come from Moshi's
``embed_codes``: a per-frame ``[B, 1 + n_q, S]`` token stack (row 0 = text,
rows 1..n_q = audio codebooks) is mapped through one text embedding plus ``n_q``
audio-codebook embeddings and summed into ``[B, S, hidden]``.
This module owns exactly those embedding tables (Moshi ``text_emb`` and
``emb.0..n_q-1``), which ``modeling_helium`` deliberately omits. The talker
(Task 2) uses it to build the temporal ``inputs_embeds`` for both the system /
voice-prompt prefill and the per-frame decode step.
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping
import torch
from torch import nn
from vllm_omni.model_executor.models.personaplex.configuration_personaplex import (
PersonaPlexConfig,
)
from vllm_omni.model_executor.models.personaplex.personaplex_depformer import (
_ScaledEmbedding,
)
__all__ = ["PersonaPlexInputEmbeddings"]
class PersonaPlexInputEmbeddings(nn.Module):
"""Moshi ``embed_codes``: summed text + audio-codebook embeddings.
Args:
config: the top-level PersonaPlex config (supplies the temporal hidden
size, the audio cardinality / codebook count, and the text table size).
"""
def __init__(self, config: PersonaPlexConfig) -> None:
super().__init__()
dim = config.temporal_config.hidden_size
self.num_audio_codebooks = config.num_audio_codebooks # n_q (16)
# Text table has one extra row for the special token (text_card + 1).
self.text_emb = _ScaledEmbedding(config.text_embedding_rows, dim)
# Each audio table is card + 1 rows; ``embed_codes`` uses all n_q codebooks.
self.audio_emb = nn.ModuleList(
[_ScaledEmbedding(config.audio_vocab_size + 1, dim) for _ in range(self.num_audio_codebooks)]
)
def forward(self, sequence: torch.Tensor) -> torch.Tensor:
"""Map a ``[B, 1 + n_q, S]`` token stack to ``[B, S, hidden]``.
Row 0 is the text stream; rows ``1..n_q`` are the audio codebooks (in
Moshi's layout the audio rows carry both the agent and user streams).
"""
if sequence.dim() != 3 or sequence.shape[1] != self.num_audio_codebooks + 1:
raise ValueError(f"sequence must be [B, {self.num_audio_codebooks + 1}, S]; got {tuple(sequence.shape)}")
emb = self.text_emb(sequence[:, 0]) # [B, S, hidden]
for cb in range(self.num_audio_codebooks):
emb = emb + self.audio_emb[cb](sequence[:, cb + 1])
return emb
def load_weights(
self,
weights: Iterable[tuple[str, torch.Tensor]] | Mapping[str, torch.Tensor],
) -> set[str]:
"""Load Moshi ``text_emb`` and ``emb.0..n_q-1`` embedding tables."""
src = dict(weights.items() if isinstance(weights, Mapping) else weights)
params = dict(self.named_parameters())
loaded: set[str] = set()
plan: dict[str, str] = {"text_emb.weight": "text_emb.weight"}
for cb in range(self.num_audio_codebooks):
plan[f"audio_emb.{cb}.weight"] = f"emb.{cb}.weight"
for tgt, key in plan.items():
if key not in src:
continue
param = params[tgt]
tensor = src[key]
if tensor.shape != param.shape:
raise ValueError(
f"shape mismatch for {tgt}: checkpoint {tuple(tensor.shape)} vs param {tuple(param.shape)}"
)
param.data.copy_(tensor.to(param.dtype))
loaded.add(tgt)
return loaded
@@ -0,0 +1,413 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Streaming Mimi codec for PersonaPlex (moshi-free).
Frame-clocked duplex needs a codec that encodes/decodes exactly one 80 ms frame
per call with state carried across the conversation. The ``moshi`` package
provided that; this module removes the dependency by combining:
- **transformers ``MimiModel``** (``kyutai/mimi`` — the same checkpoint family
PersonaPlex ships) for the quantizer and every SEANet conv weight. Its
streaming support only covers the encoder convs, so conv streaming is done
here instead, uniformly.
- **Our own streaming wrappers** mirroring the Moshi reference semantics (MIT),
verified against recorded reference outputs:
* ``Conv1d``: left-context carry of ``effective_kernel - stride`` samples,
zero-initialized at stream start (``pad_mode="constant"``).
* ``ConvTranspose1d``: overlap-add tail carry of ``kernel - stride`` output
samples, with the double-counted bias subtracted on merge.
* transformer: the encoder/decoder transformers use a 250-position sliding
context over a ring KV with absolute-offset RoPE. Hugging Face's cache path
diverges once the window engages (position 250), so the transformers are
reimplemented here on the same ring-KV design as
``personaplex_temporal.py`` and loaded directly from the PersonaPlex
checkpoint's fused layout (LayerNorm + per-layer LayerScale + GELU FFN).
All per-stream state is ``[B, ...]`` with per-row reset (``reset_slot``), so the
codec composes with elastic slot recycling in batched duplex serving.
"""
from __future__ import annotations
import torch
import torch.nn as nn
import torch.nn.functional as F
from vllm_omni.model_executor.models.personaplex.personaplex_temporal import (
_apply_rope,
_RingKV,
)
DEFAULT_HF_REPO = "kyutai/mimi"
FRAME_SIZE = 1920
CODEBOOKS = 8
def _map_moshi_codec_weights(
state_dict: dict[str, torch.Tensor],
) -> dict[str, torch.Tensor]:
"""Map bundled Moshi codec keys to ``transformers.MimiModel`` keys."""
mapped: dict[str, torch.Tensor] = {}
transformer_prefixes = ("encoder_transformer.", "decoder_transformer.")
for name, tensor in state_dict.items():
if name.startswith(transformer_prefixes):
continue
if name.startswith("encoder.model."):
target = name.replace("encoder.model.", "encoder.layers.", 1)
target = target.replace(".conv.conv.", ".conv.")
elif name.startswith("decoder.model."):
target = name.replace("decoder.model.", "decoder.layers.", 1)
target = target.replace(".convtr.convtr.", ".conv.")
target = target.replace(".conv.conv.", ".conv.")
elif name.startswith("downsample.conv.conv.conv."):
target = name.replace("downsample.conv.conv.conv.", "downsample.conv.", 1)
elif name.startswith("upsample.convtr.convtr.convtr."):
target = name.replace("upsample.convtr.convtr.convtr.", "upsample.conv.", 1)
elif name.startswith("quantizer.rvq_first."):
target = name.replace(
"quantizer.rvq_first.",
"quantizer.semantic_residual_vector_quantizer.",
1,
)
elif name.startswith("quantizer.rvq_rest."):
target = name.replace(
"quantizer.rvq_rest.",
"quantizer.acoustic_residual_vector_quantizer.",
1,
)
else:
raise KeyError(f"unrecognized PersonaPlex Mimi checkpoint key: {name}")
target = target.replace(".vq.layers.", ".layers.")
target = target.replace("._codebook._initialized", ".codebook.initialized")
target = target.replace("._codebook.cluster_usage", ".codebook.cluster_usage")
target = target.replace("._codebook.embedding_sum", ".codebook.embed_sum")
mapped[target] = tensor
return mapped
class _StreamConv1d:
"""Moshi ``RawStreamingConv1d``: causal left-context carry per call.
``pad_mode`` controls the stream-start left padding: SEANet convs use zeros
(``constant``); the down/upsample resamplers use ``replicate`` (the first
real sample), marked per row so elastic slot recycling re-primes correctly.
"""
def __init__(self, conv: nn.Conv1d, pad_mode: str = "constant") -> None:
self.conv = conv
self.kernel = (conv.kernel_size[0] - 1) * conv.dilation[0] + 1
self.stride = conv.stride[0]
self.pad_mode = pad_mode
self.prev: torch.Tensor | None = None
self._fresh: torch.Tensor | None = None
def reset(self, batch_size: int, device, dtype) -> None:
pad = self.kernel - self.stride
self.prev = torch.zeros(batch_size, self.conv.in_channels, pad, device=device, dtype=dtype)
self._fresh = torch.ones(batch_size, dtype=torch.bool)
def reset_slot(self, b: int) -> None:
self.prev[b].zero_()
self._fresh[b] = True
def __call__(self, x: torch.Tensor) -> torch.Tensor:
if self.pad_mode == "replicate" and bool(self._fresh.any()):
pad = self.prev.shape[-1]
edge = x[..., 0:1].expand(-1, -1, pad)
fresh = self._fresh.to(x.device).view(-1, 1, 1)
self.prev = torch.where(fresh, edge.to(self.prev.dtype), self.prev)
self._fresh[:] = False
x = torch.cat([self.prev, x], dim=-1)
t = x.shape[-1]
num_frames = max(0, (t - self.kernel) // self.stride + 1)
self.prev = x[..., num_frames * self.stride :]
if num_frames == 0:
return x.new_zeros(x.shape[0], self.conv.out_channels, 0)
return self.conv(x[..., : (num_frames - 1) * self.stride + self.kernel])
class _StreamConvTr1d:
"""Moshi ``RawStreamingConvTranspose1d``: overlap-add tail carry per call."""
def __init__(self, conv: nn.ConvTranspose1d) -> None:
self.conv = conv
self.kernel = conv.kernel_size[0]
self.stride = conv.stride[0]
self.partial: torch.Tensor | None = None
def reset(self, batch_size: int, device, dtype) -> None:
self.partial = torch.zeros(
batch_size, self.conv.out_channels, self.kernel - self.stride, device=device, dtype=dtype
)
self._fresh = torch.ones(batch_size, dtype=torch.bool)
def reset_slot(self, b: int) -> None:
self.partial[b].zero_()
self._fresh[b] = True
def __call__(self, x: torch.Tensor) -> torch.Tensor:
out = self.conv(x)
length = out.shape[-1]
tail = self.kernel - self.stride
pt = self.partial.shape[-1]
merge = self.partial.clone()
if self.conv.bias is not None:
# The carried tail already includes the bias; the fresh output adds
# it again, so subtract one copy -- except on a row's very first
# frame, where the carry is zeros by construction.
merge = merge - self.conv.bias[:, None]
merge[self._fresh] = 0.0
self._fresh[:] = False
out[..., :pt] += merge
self.partial = out[..., length - tail :].clone()
return out[..., : length - tail]
class _MimiTransformerLayer(nn.Module):
"""Moshi mimi transformer layer: LayerNorm, LayerScale, GELU FFN, no biases."""
def __init__(self, dim: int = 512, num_heads: int = 8, ffn: int = 2048) -> None:
super().__init__()
self.dim = dim
self.num_heads = num_heads
self.head_dim = dim // num_heads
self.in_proj_weight = nn.Parameter(torch.empty(3 * dim, dim))
self.out_proj_weight = nn.Parameter(torch.empty(dim, dim))
self.linear1 = nn.Parameter(torch.empty(ffn, dim))
self.linear2 = nn.Parameter(torch.empty(dim, ffn))
self.norm1 = nn.LayerNorm(dim)
self.norm2 = nn.LayerNorm(dim)
self.scale1 = nn.Parameter(torch.empty(dim))
self.scale2 = nn.Parameter(torch.empty(dim))
def forward(self, x: torch.Tensor, kv: _RingKV, offset: torch.Tensor, context: int) -> torch.Tensor:
B, T, _ = x.shape
h = self.norm1(x)
qkv = F.linear(h, self.in_proj_weight)
qkv = qkv.view(B, T, 3, self.num_heads, self.head_dim).permute(2, 0, 3, 1, 4)
q, k, v = qkv[0], qkv[1], qkv[2]
q, k = _apply_rope(q, k, offset)
keys, values, pos_k = kv.complete(k, v)
pos_k = pos_k.view(pos_k.shape[0], 1, pos_k.shape[1])
pos_q = offset + torch.arange(T, device=q.device, dtype=torch.long).view(1, -1, 1)
delta = pos_q - pos_k
attn_bias = (pos_k >= 0) & (delta >= 0) & (delta < context)
attn = F.scaled_dot_product_attention(q, keys, values, attn_bias.unsqueeze(1), dropout_p=0.0)
attn = attn.transpose(1, 2).reshape(B, T, self.dim)
x = x + self.scale1 * F.linear(attn, self.out_proj_weight)
h = self.norm2(x)
h = F.linear(F.gelu(F.linear(h, self.linear1)), self.linear2)
return x + self.scale2 * h
class _MimiStreamingTransformer(nn.Module):
"""The 8-layer mimi encoder/decoder transformer as a stateful stepper."""
def __init__(self, num_layers: int = 8, dim: int = 512, num_heads: int = 8, context: int = 250) -> None:
super().__init__()
self.context = context
self.layers = nn.ModuleList([_MimiTransformerLayer(dim, num_heads) for _ in range(num_layers)])
self._kv: list[_RingKV] | None = None
self._offset: torch.Tensor | None = None
def streaming_init(self, batch_size: int) -> None:
p = next(self.parameters())
heads = self.layers[0].num_heads
hd = self.layers[0].head_dim
self._kv = [_RingKV(batch_size, heads, hd, self.context, p.device, p.dtype) for _ in self.layers]
self._offset = torch.zeros(1, device=p.device, dtype=torch.long)
def reset_streaming(self) -> None:
for kv in self._kv:
kv.reset()
self._offset.zero_()
def reset_slot(self, b: int) -> None:
for kv in self._kv:
kv.reset_slot(b)
def step(self, x: torch.Tensor) -> torch.Tensor:
"""``x`` is ``[B, T, dim]`` (T = positions this frame, typically 2)."""
for layer, kv in zip(self.layers, self._kv):
x = layer(x, kv, self._offset, self.context)
self._offset.add_(x.shape[1])
return x
def load_weights(self, state_dict: dict[str, torch.Tensor], prefix: str) -> int:
loaded = 0
with torch.no_grad():
for i, layer in enumerate(self.layers):
base = f"{prefix}.transformer.layers.{i}"
pairs = [
(layer.in_proj_weight, f"{base}.self_attn.in_proj_weight"),
(layer.out_proj_weight, f"{base}.self_attn.out_proj.weight"),
(layer.linear1, f"{base}.linear1.weight"),
(layer.linear2, f"{base}.linear2.weight"),
(layer.norm1.weight, f"{base}.norm1.weight"),
(layer.norm1.bias, f"{base}.norm1.bias"),
(layer.norm2.weight, f"{base}.norm2.weight"),
(layer.norm2.bias, f"{base}.norm2.bias"),
(layer.scale1, f"{base}.layer_scale_1.scale"),
(layer.scale2, f"{base}.layer_scale_2.scale"),
]
for param, name in pairs:
param.data.copy_(state_dict[name].reshape(param.shape).to(param.dtype))
loaded += 1
return loaded
def _walk_seanet(layers) -> list[tuple[str, object]]:
"""Wrap a Hugging Face Mimi SEANet layer list with streaming conv state."""
stages: list[tuple[str, object]] = []
for layer in layers:
kind = type(layer).__name__
if kind == "MimiConv1d":
stages.append(("conv", _StreamConv1d(layer.conv)))
elif kind == "MimiConvTranspose1d":
stages.append(("convtr", _StreamConvTr1d(layer.conv)))
elif kind == "ELU":
stages.append(("act", layer))
elif kind == "MimiResnetBlock":
block = (
layer.block[0],
_StreamConv1d(layer.block[1].conv),
layer.block[2],
_StreamConv1d(layer.block[3].conv),
)
stages.append(("res", block))
else: # pragma: no cover - unexpected layer type
raise ValueError(f"unhandled Mimi SEANet layer: {kind}")
return stages
class PersonaPlexMimiCodec(nn.Module):
"""Streaming Mimi encode/decode at one 80 ms frame per call (moshi-free)."""
def __init__(self, hf_repo: str = DEFAULT_HF_REPO, checkpoint: str | None = None, device: str = "cuda") -> None:
super().__init__()
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
from transformers import MimiConfig, MimiModel
self.device = torch.device(device)
# The PersonaPlex repo ships the reference mimi checkpoint in the moshi
# fused layout. Build the matching Transformers graph locally, then load
# every codec tensor from that bundled checkpoint. Only the two HF
# transformer stacks remain absent because the streaming implementations
# below replace them with the checkpoint's fused QKV layout.
if checkpoint is None:
checkpoint = hf_hub_download(
"nvidia/personaplex-7b-v1",
"tokenizer-e351c8d8-checkpoint125.safetensors",
)
sd = load_file(checkpoint, device=str(self.device))
self.model = MimiModel(MimiConfig())
codec_state = _map_moshi_codec_weights(sd)
incompatible = self.model.load_state_dict(codec_state, strict=False)
expected_missing = {
name
for name in self.model.state_dict()
if name.startswith(("encoder_transformer.", "decoder_transformer."))
}
if set(incompatible.missing_keys) != expected_missing or incompatible.unexpected_keys:
raise RuntimeError(
"PersonaPlex Mimi checkpoint did not exactly cover the local codec graph: "
f"missing={sorted(set(incompatible.missing_keys) - expected_missing)}, "
f"unexpected={sorted(incompatible.unexpected_keys)}"
)
# The fused streaming transformers below replace these unloaded HF
# stacks. Drop their randomly initialized weights before moving Mimi to
# the target device.
del self.model.encoder_transformer, self.model.decoder_transformer
self.model = self.model.to(self.device).eval()
self.dtype = next(self.model.parameters()).dtype
self.encoder_transformer = _MimiStreamingTransformer().to(self.device, self.dtype)
self.decoder_transformer = _MimiStreamingTransformer().to(self.device, self.dtype)
n_enc = self.encoder_transformer.load_weights(sd, "encoder_transformer")
n_dec = self.decoder_transformer.load_weights(sd, "decoder_transformer")
assert n_enc == n_dec == 80, (n_enc, n_dec)
del sd
m = self.model
self._enc_stages = _walk_seanet(m.encoder.layers)
self._downsample = _StreamConv1d(m.downsample.conv, pad_mode="replicate")
self._upsample = _StreamConvTr1d(m.upsample.conv)
self._dec_stages = _walk_seanet(m.decoder.layers)
self._batch_size: int | None = None
# -- streaming state ------------------------------------------------------
def _conv_states(self):
for _, stage in (*self._enc_stages, *self._dec_stages):
if isinstance(stage, (_StreamConv1d, _StreamConvTr1d)):
yield stage
elif isinstance(stage, tuple):
yield stage[1]
yield stage[3]
yield self._downsample
yield self._upsample
def streaming_init(self, batch_size: int) -> None:
self._batch_size = batch_size
for s in self._conv_states():
s.reset(batch_size, self.device, self.dtype)
self.encoder_transformer.streaming_init(batch_size)
self.decoder_transformer.streaming_init(batch_size)
def reset_streaming(self) -> None:
assert self._batch_size is not None
for state in self._conv_states():
for b in range(self._batch_size):
state.reset_slot(b)
self.encoder_transformer.reset_streaming()
self.decoder_transformer.reset_streaming()
def reset_slot(self, b: int) -> None:
for s in self._conv_states():
s.reset_slot(b)
self.encoder_transformer.reset_slot(b)
self.decoder_transformer.reset_slot(b)
# -- per-frame codec -------------------------------------------------------
@staticmethod
def _run_stages(x: torch.Tensor, stages) -> torch.Tensor:
for kind, stage in stages:
if kind == "res":
act0, conv1, act2, conv3 = stage
x = x + conv3(act2(conv1(act0(x))))
else:
x = stage(x)
return x
@torch.no_grad()
def encode_frame(self, pcm: torch.Tensor) -> torch.Tensor:
"""``[B, frame_size]`` float PCM -> ``[B, 8]`` codes."""
x = pcm.to(self.device, self.dtype).view(-1, 1, FRAME_SIZE)
x = self._run_stages(x, self._enc_stages)
x = self.encoder_transformer.step(x.transpose(1, 2)).transpose(1, 2)
x = self._downsample(x)
codes = self.model.quantizer.encode(x) # [Q, B, T]
return codes[:CODEBOOKS, :, 0].transpose(0, 1).contiguous()
@torch.no_grad()
def decode_frame(self, codes: torch.Tensor) -> torch.Tensor:
"""``[B, 8]`` codes -> ``[B, frame_size]`` float PCM."""
emb = self.model.quantizer.decode(codes.to(self.device).view(-1, CODEBOOKS, 1))
emb = self._upsample(emb)
emb = self.decoder_transformer.step(emb.transpose(1, 2)).transpose(1, 2)
x = self._run_stages(emb, self._dec_stages)
return x[:, 0, :]
@torch.no_grad()
def decode_frames(self, codes: torch.Tensor) -> torch.Tensor:
"""``[B, 8, F]`` codes -> ``[B, F * frame_size]`` float PCM."""
emb = self.model.quantizer.decode(codes.to(self.device))
emb = self._upsample(emb)
emb = self.decoder_transformer.step(emb.transpose(1, 2)).transpose(1, 2)
x = self._run_stages(emb, self._dec_stages)
return x[:, 0, :]
@@ -0,0 +1,639 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""PersonaPlex talker: the temporal transformer as a vLLM-native omni AR stage.
This is the stage-0 (``LLM_AR``) model the OmniGPUModelRunner drives. It composes
three pieces, each verified in isolation against Moshi:
* the Helium temporal transformer (:class:`HeliumModel`) on vLLM paged attention,
consuming per-frame ``inputs_embeds`` and producing the per-frame hidden state;
* the input embeddings (:class:`PersonaPlexInputEmbeddings`, ``embed_codes``) that
build those ``inputs_embeds`` from the delayed 17-row token stack;
* the depformer (:class:`PersonaPlexDepformer`) that, conditioned on the temporal
hidden state and the sampled text token, predicts the per-frame audio codes.
Per-frame protocol (OmniGPUModelRunner, gpu_model_runner.py):
1. ``compute_logits`` produces the text logits; the engine samples the text token.
2. ``preprocess`` (per-request, with that request's ``additional_information``)
carries Moshi's acoustic-delay cache and the precomputed user-audio code stream,
builds the base ``inputs_embeds`` for the current frame, and exposes the
previous frame's temporal hidden + text-step embedding via ``mtp_inputs``.
3. ``talker_mtp`` (batched, stateless) runs the depformer to predict the agent
codes and finishes the next frame's ``inputs_embeds``; the codes are stored
under ``talker_mtp_output_key=("codes","audio")`` for the Mimi code2wav stage.
Phase 1 is turn-based: the user-audio rows (9..16) come from a precomputed Mimi
encode of the input WAV (built in ``preprocess``); live duplex is Phase 2.
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
import torch
from torch import nn
from vllm.config import VllmConfig
from vllm.distributed import get_pp_group
from vllm.model_executor.layers.logits_processor import LogitsProcessor
from vllm.model_executor.layers.vocab_parallel_embedding import ParallelLMHead
from vllm.model_executor.model_loader.weight_utils import default_weight_loader
from vllm.model_executor.models.utils import PPMissingLayer, maybe_prefix
from vllm.sequence import IntermediateTensors
from vllm_omni.model_executor.models.output_templates import OmniOutput
from vllm_omni.model_executor.models.personaplex.configuration_personaplex import (
PersonaPlexConfig,
)
from vllm_omni.model_executor.models.personaplex.modeling_helium import HeliumModel
from vllm_omni.model_executor.models.personaplex.personaplex_depformer import (
PersonaPlexDepformer,
)
from vllm_omni.model_executor.models.personaplex.personaplex_embeddings import (
PersonaPlexInputEmbeddings,
)
__all__ = ["PersonaPlexTalkerForConditionalGeneration"]
class PersonaPlexTalkerForConditionalGeneration(nn.Module):
"""vLLM-native PersonaPlex talker (temporal transformer + depformer).
Plain ``nn.Module`` (not ``SupportsPP``): inheriting a vLLM Protocol puts
``Protocol`` in the MRO and breaks vLLM's runtime ``isinstance`` check for
``VllmModelForTextGeneration``, which would misclassify the talker as a
non-generate model. Qwen3-TTS's talker is plain ``nn.Module`` for the same
reason; PersonaPlex does not need pipeline parallelism.
"""
def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
super().__init__()
self.vllm_config = vllm_config
config: PersonaPlexConfig = vllm_config.model_config.hf_config # type: ignore[assignment]
self.config = config
self.temporal_config = config.temporal_config
hidden = self.temporal_config.hidden_size
self._dtype = getattr(vllm_config.model_config, "dtype", torch.bfloat16)
# Temporal backbone on vLLM paged attention (consumes inputs_embeds).
self.model = HeliumModel(
vllm_config=vllm_config,
prefix=maybe_prefix(prefix, "model"),
config=self.temporal_config,
)
# Text head (Moshi text_linear -> lm_head), vocab = text_vocab_size (32000).
if get_pp_group().is_last_rank:
self.lm_head = ParallelLMHead(
config.text_vocab_size,
hidden,
quant_config=vllm_config.quant_config,
prefix=maybe_prefix(prefix, "lm_head"),
)
else:
self.lm_head = PPMissingLayer()
self.logits_processor = LogitsProcessor(config.text_vocab_size)
self.make_empty_intermediate_tensors = self.model.make_empty_intermediate_tensors
# Verified custom components: embed_codes + depformer.
self.input_embeddings = PersonaPlexInputEmbeddings(config)
self.depformer = PersonaPlexDepformer(
config.depformer_config,
temporal_hidden_size=hidden,
text_card=config.text_vocab_size,
)
# Omni AR runner contract.
self.have_multimodal_outputs = True
self.has_preprocess = True
self.has_postprocess = True # capture each frame's hidden for the next depformer step
self.requires_full_prefix_cached_hidden_states = False
# Keep the per-frame "last" hidden on GPU (avoids a CPU round-trip each step).
self.gpu_resident_buffer_keys: set[tuple[str, str]] = {("hidden_states", "last")}
self.mtp_hidden_size = hidden
self.talker_mtp_output_key = ("codes", "audio")
# dep_q audio codebooks per frame; only cb 0..num_active are vocoded.
self.dep_q = config.depformer_config.dep_q
self.num_active_codebooks = config.depformer_config.num_active_codebooks
# ------------------------------------------------------------------
# Core forward / logits
# ------------------------------------------------------------------
def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor:
"""Placeholder embedding for vLLM's ``VllmModel`` protocol.
The talker is driven by precomputed ``inputs_embeds`` (built in
``preprocess`` via ``embed_codes``); ``input_ids`` are only in-vocab
bookkeeping placeholders. Return a zero embedding of the temporal hidden
size — the runner replaces it with the real per-frame ``inputs_embeds``.
"""
return torch.zeros(
(input_ids.shape[0], self.mtp_hidden_size),
device=input_ids.device,
dtype=self._dtype,
)
def forward(
self,
input_ids: torch.Tensor | None,
positions: torch.Tensor,
intermediate_tensors: IntermediateTensors | None = None,
inputs_embeds: torch.Tensor | None = None,
**_: Any,
) -> torch.Tensor | IntermediateTensors:
return self.model(input_ids, positions, intermediate_tensors, inputs_embeds)
def compute_logits(
self,
hidden_states: torch.Tensor | OmniOutput,
sampling_metadata: Any = None,
) -> torch.Tensor | None:
if isinstance(hidden_states, OmniOutput):
hidden_states = hidden_states.text_hidden_states
if hidden_states is None:
return None
if hidden_states.dim() == 3:
b, s, h = hidden_states.shape
logits = self.logits_processor(self.lm_head, hidden_states.reshape(b * s, h))
return None if logits is None else logits.reshape(b, s, -1)
return self.logits_processor(self.lm_head, hidden_states)
def make_omni_output(self, model_outputs: torch.Tensor | OmniOutput, **kwargs: Any) -> OmniOutput:
if isinstance(model_outputs, OmniOutput):
return model_outputs
hidden = model_outputs
info_dicts = kwargs.get("model_intermediate_buffer") or kwargs.get("runtime_additional_information") or []
audio_codes_list: list[torch.Tensor] = []
for info in info_dicts:
if not isinstance(info, dict):
continue
ac = info.get("codes", {}).get("audio")
if isinstance(ac, torch.Tensor):
audio_codes_list.append(ac)
if not audio_codes_list:
return OmniOutput(text_hidden_states=hidden, multimodal_outputs={})
audio_codes = torch.cat(audio_codes_list, dim=0)
hidden = hidden[: int(audio_codes.shape[0])]
return OmniOutput(text_hidden_states=hidden, multimodal_outputs={"codes": {"audio": audio_codes}})
# ------------------------------------------------------------------
# Per-frame omni AR protocol: preprocess (stateful) + talker_mtp (batched)
# ------------------------------------------------------------------
def _initial_frame_embed(self, device: torch.device) -> torch.Tensor:
"""``embed_codes`` of Moshi's initial token frame (text+audio SOS)."""
text_init = self.config.text_vocab_size # text_initial_token_id
audio_init = self.config.audio_vocab_size # card == initial audio token
n_q = self.config.num_audio_codebooks
stack = torch.empty((1, 1 + n_q, 1), dtype=torch.long, device=device)
stack[:, 0] = text_init
stack[:, 1:] = audio_init
return self.input_embeddings(stack).reshape(1, -1) # [1, hidden]
def _build_frame_embed(
self,
text_token: torch.Tensor,
last_agent: torch.Tensor | None,
prev_agent: torch.Tensor | None,
device: torch.device,
user_d0: torch.Tensor | None = None,
user_d1: torch.Tensor | None = None,
) -> torch.Tensor:
"""Full ``inputs_embeds`` for a decode frame (Moshi cache read at offset-1).
Acoustic-delay decomposition (delays ``[0, 0,1x7, 0,1x7]``): after a
depformer step, Moshi writes every unforced agent codebook into the
current cache target. The next temporal input therefore reads the whole
previous effective agent frame. User writes are delayed by codebook, so
user cb0 and cb1..7 still come from ``user_d0`` and ``user_d1``.
Unfilled positions default to the initial token (card).
"""
n_q = self.config.num_audio_codebooks # 16 (8 agent + 8 user)
n_user = n_q // 2 # 8
audio_init = self.config.audio_vocab_size # card == initial audio token (Moshi cache default)
# No-history / not-yet-generated delayed positions = the initial token (card),
# NOT 0 (verified against Moshi's per-frame input stack).
stack = torch.full((1, 1 + n_q, 1), audio_init, dtype=torch.long, device=device)
stack[:, 0] = text_token.reshape(1)
# The full effective agent frame was stored at the previous cache target
# after applying any teacher forcing. The next cache read sees all eight
# codebooks from that frame.
if last_agent is not None and last_agent.numel() >= n_user:
stack[0, 1 : 1 + n_user, 0] = last_agent.reshape(-1)[:n_user].to(device)
# user cb0 (row 9) delay-0; user cb1..7 (rows 10..16) delay-1.
user_base = 1 + n_user # row index 9
if user_d0 is not None and user_d0.numel() >= 1:
stack[0, user_base, 0] = user_d0.reshape(-1)[0].to(device)
if user_d1 is not None and user_d1.numel() >= n_user:
stack[0, user_base + 1 : user_base + n_user, 0] = user_d1.reshape(-1)[1:n_user].to(device)
return self.input_embeddings(stack).reshape(1, -1) # [1, hidden]
def _build_prefill_embed(
self,
prefill_text: torch.Tensor,
offset: int,
span: int,
device: torch.device,
silence: torch.Tensor | None = None,
user_sine: torch.Tensor | None = None,
) -> torch.Tensor:
"""Per-frame embeds for the persona system-prompt prefill.
Each frame forces the persona/silence text token (row 0). The agent rows
use Mimi-encoded silence, while the user rows optionally use the encoded
sine frame from PersonaPlex's native text-prompt prefill. Without an
explicit user frame, both sides fall back to silence.
"""
n_q = self.config.num_audio_codebooks
n_user = n_q // 2
zero_text = 3 # Moshi LMGen.zero_text_code (silence/pad text)
sil = (
silence.reshape(-1).to(device) if isinstance(silence, torch.Tensor) and silence.numel() >= n_user else None
)
user = (
user_sine.reshape(-1).to(device)
if isinstance(user_sine, torch.Tensor) and user_sine.numel() >= n_user
else sil
)
total = int(prefill_text.numel())
rows = []
for i in range(span):
pos = offset + i
text_tok = int(prefill_text[pos].item()) if pos < total else zero_text
stack = torch.zeros((1, 1 + n_q, 1), dtype=torch.long, device=device)
stack[:, 0] = text_tok
if sil is not None:
stack[0, 1 : 1 + n_user, 0] = sil[:n_user] # agent rows = encoded silence
if user is not None:
stack[0, 1 + n_user : 1 + 2 * n_user, 0] = user[:n_user]
rows.append(self.input_embeddings(stack).reshape(1, -1))
return torch.cat(rows, dim=0) # [span, hidden]
@staticmethod
def _user_frame(info: dict[str, Any], frame_idx: int) -> torch.Tensor | None:
"""Fetch the Mimi-encoded user codes for ``frame_idx`` (or None / out of range)."""
uc = info.get("pplex_user_codes")
if uc is None:
return None
if not isinstance(uc, torch.Tensor):
uc = torch.as_tensor(uc, dtype=torch.long)
if uc.ndim != 2 or frame_idx < 0 or frame_idx >= uc.shape[0]:
return None
return uc[frame_idx]
@staticmethod
def _last_agent_codes(info: dict[str, Any]) -> torch.Tensor | None:
codes = info.get("codes")
if isinstance(codes, dict):
ac = codes.get("audio")
if isinstance(ac, torch.Tensor) and ac.numel() > 0:
return (ac[-1] if ac.ndim == 2 else ac).reshape(-1)
return None
def preprocess(
self,
input_ids: torch.Tensor,
input_embeds: torch.Tensor | None,
**info_dict: Any,
) -> tuple[torch.Tensor, torch.Tensor, dict[str, Any]]:
"""Build the current frame's ``inputs_embeds`` and the mtp inputs.
Prefill emits the initial-token frame embedding; each decode step builds the
delayed base frame (everything but agent cb0, which ``talker_mtp`` adds).
"""
additional = info_dict.get("additional_information")
if isinstance(additional, dict):
merged = {k: v for k, v in info_dict.items() if k != "additional_information"}
for k, v in additional.items():
merged.setdefault(k, v)
info_dict = merged
meta = info_dict.get("meta", {}) if isinstance(info_dict.get("meta"), dict) else {}
device = input_ids.device
span = int(input_ids.shape[0])
is_prefill_raw = info_dict.get("_omni_is_prefill")
if isinstance(is_prefill_raw, bool):
is_prefill = is_prefill_raw
else:
is_prefill = span > 1
duplex = info_dict.get("duplex")
if isinstance(duplex, dict) and duplex.get("data_plane") is True and is_prefill:
prompt_len_raw = info_dict.get("duplex_prompt_len", span)
try:
prompt_len = int(prompt_len_raw)
except (TypeError, ValueError):
prompt_len = span
prepared = self._duplex_stage0_runtime().prepare_append(
duplex,
prompt_len=prompt_len,
request_id=(str(info_dict["request_id"]) if isinstance(info_dict.get("request_id"), str) else None),
)
offset_raw = info_dict.get("duplex_token_offset", 0)
try:
offset = max(0, int(offset_raw))
except (TypeError, ValueError):
offset = 0
local_offset = offset - prepared.prompt_offset
if local_offset < 0:
raise ValueError(
"PersonaPlex scheduled span precedes the current append: "
f"offset={offset}, append_offset={prepared.prompt_offset}, "
f"span={span}, prompt={prompt_len}"
)
req_embeds = prepared.inputs_embeds[local_offset : local_offset + span].to(
device=device,
dtype=self._dtype,
)
req_input_ids = prepared.input_ids[local_offset : local_offset + span].to(device=device)
if req_embeds.shape[0] != span or req_input_ids.shape[0] != span:
raise ValueError(
"PersonaPlex duplex prompt slice is shorter than the scheduled span: "
f"offset={offset}, span={span}, prompt={prompt_len}"
)
return req_input_ids, req_embeds, prepared.info_update
zero_hidden = torch.zeros((1, self.mtp_hidden_size), device=device, dtype=self._dtype)
prefill_text = info_dict.get("pplex_prefill_text")
prefill_len = int(prefill_text.numel()) if isinstance(prefill_text, torch.Tensor) else 0
if is_prefill:
offset = max(0, int(info_dict.get("_omni_num_computed_tokens", 0) or 0))
if isinstance(prefill_text, torch.Tensor) and prefill_text.numel() > 0:
# Persona system-prompt prefill (step_system_prompts analog).
silence = info_dict.get("pplex_silence_codes")
emb = self._build_prefill_embed(prefill_text, offset, span, device, silence)
else:
emb = self._initial_frame_embed(device)
if span > 1:
emb = emb.expand(span, -1).contiguous()
ids_out = input_ids.clone()
info_update = {
"meta": {
"pplex_frame": int(meta.get("pplex_frame", 0)) + span,
"pplex_prefill_len": prefill_len,
},
"mtp_inputs": (zero_hidden, zero_hidden),
}
return ids_out, emb, info_update
# Decode: input_ids == the text token sampled last step (delay-0 text).
text_token = input_ids.reshape(-1)[:1]
# Agent acoustic delay: cb0 (delay 0) = gen[t-1] = the last stored codes;
# cb1..7 (delay 1) = gen[t-2] = the codes carried from the previous frame.
last_agent = self._last_agent_codes(info_dict)
prev_agent = info_dict.get("pplex_prev_agent")
if prev_agent is not None and not isinstance(prev_agent, torch.Tensor):
prev_agent = torch.as_tensor(prev_agent, dtype=torch.long)
# Real user stream (Phase-1 turn-based): user cb0 delay-0, cb1..7 delay-1.
# Index into the user stream relative to the first decode frame (after the
# persona prefill), so the user audio aligns with the agent's response.
prefill_len = int(meta.get("pplex_prefill_len", prefill_len) or 0)
# decode_frame = pplex_frame - prefill_len (the prefill frame already advanced
# pplex_frame, so this is d+1 at decode step d). Verified vs Moshi's per-frame
# stack: user cb0 (delay 0) = enc[decode_frame-1] = enc[d]; cb1..7 (delay 1) =
# enc[decode_frame-2]. (The agent stream lags one more: agent cb0 = gen[d-1].)
decode_frame = max(0, int(meta.get("pplex_frame", 0)) - prefill_len)
user_d0 = self._user_frame(info_dict, decode_frame - 1)
user_d1 = self._user_frame(info_dict, decode_frame - 2)
base = self._build_frame_embed(text_token, last_agent, prev_agent, device, user_d0, user_d1)
text_step = self.input_embeddings.text_emb(text_token.reshape(1, 1)).reshape(1, -1)
# The previous frame's temporal hidden (written by postprocess into
# hidden_states["last"]) conditions this frame's depformer. Falls back to
# zeros only on the very first decode step (no prior hidden yet).
hs = info_dict.get("hidden_states", {}) if isinstance(info_dict.get("hidden_states"), dict) else {}
last_hidden = hs.get("last")
if isinstance(last_hidden, torch.Tensor) and last_hidden.numel() > 0:
last_hidden = last_hidden.reshape(1, -1).to(device=device, dtype=self._dtype)
else:
last_hidden = zero_hidden
info_update = {
"meta": {"pplex_frame": int(meta.get("pplex_frame", 0)) + 1},
"mtp_inputs": (last_hidden, text_step),
}
# Carry this frame's gen[t-1] forward; next frame reads it as gen[t-2] (cb1..7).
if last_agent is not None:
info_update["pplex_prev_agent"] = last_agent.detach().to(torch.long).cpu()
return input_ids, base, info_update
def _duplex_stage0_runtime(self):
runtime = getattr(self, "_personaplex_duplex_stage0_runtime", None)
if runtime is not None:
return runtime
from vllm_omni.experimental.fullduplex.personaplex.stage0 import (
PersonaPlexStage0DuplexRuntime,
)
model_path = str(getattr(self.vllm_config.model_config, "model", ""))
device = str(next(self.parameters()).device)
runtime = PersonaPlexStage0DuplexRuntime(
self,
model_path=model_path,
device=device,
max_sessions=int(getattr(self.vllm_config.model_config, "duplex_max_sessions", 1)),
)
self._personaplex_duplex_stage0_runtime = runtime
return runtime
def on_requests_finished(self, finished_req_ids: set[str] | list[str]) -> None:
runtime = getattr(self, "_personaplex_duplex_stage0_runtime", None)
if runtime is None:
return
for request_id in finished_req_ids:
runtime.close_request(request_id)
def postprocess(self, hidden_states: torch.Tensor, **_: Any) -> dict[str, Any]:
"""Capture this frame's last hidden for the next step's depformer (mtp)."""
if hidden_states is None or hidden_states.numel() == 0:
return {}
return {"hidden_states": {"last": hidden_states[-1, :].detach()}}
def talker_mtp(
self,
input_ids: torch.Tensor,
input_embeds: torch.Tensor,
last_talker_hidden: torch.Tensor,
text_step: torch.Tensor,
**kwargs: Any,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Run the depformer for the frame and finish its ``inputs_embeds``.
Returns ``(inputs_embeds, audio_codes[B, dep_q])``. ``audio_codes`` are
stored under ``("codes","audio")``; ``inputs_embeds`` (base from preprocess
+ the fresh agent cb0 embedding, the delay-0 acoustic token) feeds the next
temporal forward.
"""
bsz = int(input_ids.shape[0])
dtype = self._dtype
text_token = input_ids.reshape(bsz).to(torch.long)
hidden = last_talker_hidden.reshape(bsz, 1, -1).to(dtype)
codes = self.depformer(text_token, hidden) # [B, dep_q] == gen[t]
# The frame's inputs_embeds is built fully in preprocess (Moshi cache read at
# offset-1, with agent cb0 = gen[t-1]); gen[t] feeds the NEXT frame, so pass
# the embed through unchanged and just emit this frame's codes.
inputs_embeds = input_embeds.reshape(bsz, -1).to(dtype)
return inputs_embeds, codes.to(torch.long)
def post_sample_talker_mtp(
self,
*,
input_ids: torch.Tensor,
hidden_states: torch.Tensor,
req_ids: list[str],
req_infos: list[dict[str, Any]],
) -> torch.Tensor:
"""Generate depformer codes for a one-token resumable duplex segment.
The normal runner invokes ``talker_mtp`` at the start of the next decode
step. PersonaPlex's unified duplex request instead appends one audio
frame and stops after one sampled text token, so there is no next decode
step. Run the same depformer dependency immediately from the current
sampled text token and temporal hidden state.
"""
bsz = int(input_ids.shape[0])
if len(req_infos) != bsz:
raise ValueError(
f"PersonaPlex depformer request information does not match batch: {len(req_infos)} != {bsz}"
)
text_token = input_ids.reshape(bsz).to(torch.long)
hidden = hidden_states.reshape(bsz, 1, -1).to(self._dtype)
audio_tokens: list[torch.Tensor] = []
audio_provided: list[torch.Tensor] = []
for info in req_infos:
tokens = info.get("pplex_depformer_audio_tokens")
provided = info.get("pplex_depformer_audio_provided")
if not isinstance(tokens, torch.Tensor) or not isinstance(provided, torch.Tensor):
raise ValueError("PersonaPlex duplex depformer teacher-forcing state is missing")
tokens = tokens.reshape(-1)
provided = provided.reshape(-1)
if tokens.shape != provided.shape:
raise ValueError(
"PersonaPlex depformer teacher-forcing token/mask shapes differ: "
f"{tuple(tokens.shape)} != {tuple(provided.shape)}"
)
audio_tokens.append(tokens)
audio_provided.append(provided)
codes = self.depformer(
text_token,
hidden,
audio_tokens=torch.stack(audio_tokens).to(
device=hidden.device,
dtype=torch.long,
),
audio_provided=torch.stack(audio_provided).to(
device=hidden.device,
dtype=torch.bool,
),
).to(torch.long)
runtime = self._duplex_stage0_runtime()
for row, request_id in enumerate(req_ids):
runtime.record_sample(
request_id=request_id,
text_token=text_token[row],
agent_codes=codes[row],
)
return codes
# ------------------------------------------------------------------
# Weight loading
# ------------------------------------------------------------------
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
"""Route the Moshi checkpoint into the three components.
* ``transformer.*`` / ``out_norm.alpha`` -> the Helium temporal backbone
(same q/k-split + gate/up-split + alpha-squeeze map as HeliumForCausalLM).
* ``text_linear.weight`` -> ``lm_head``.
* ``emb.*`` / ``text_emb.weight`` -> input embeddings.
* ``depformer*`` / ``linears.*`` -> depformer.
"""
weights = list(weights)
params = dict(self.named_parameters(remove_duplicate=False))
loaded: set[str] = set()
emb_w: list[tuple[str, torch.Tensor]] = []
dep_w: list[tuple[str, torch.Tensor]] = []
for name, w in weights:
if name.startswith(("emb.", "text_emb.")):
emb_w.append((name, w))
elif name.startswith(("depformer.", "depformer_in.", "depformer_emb.", "depformer_text_emb", "linears.")):
dep_w.append((name, w))
elif name == "text_linear.weight":
self._load_direct("lm_head.weight", w, params, loaded)
else:
loaded |= self._load_temporal(name, w, params)
# Delegate to the verified component loaders, prefixed into this module.
for sub, sub_w in (("input_embeddings", emb_w), ("depformer", dep_w)):
module = getattr(self, sub)
for tgt in module.load_weights(sub_w):
loaded.add(f"{sub}.{tgt}")
return loaded
def _load_temporal(
self,
name: str,
loaded_weight: torch.Tensor,
params: dict[str, nn.Parameter],
) -> set[str]:
out: set[str] = set()
if name == "out_norm.alpha":
self._load_direct("model.norm.weight", loaded_weight.squeeze(), params, out)
return out
prefix = "transformer.layers."
if not name.startswith(prefix):
return out
rest = name.removeprefix(prefix)
layer_index, _, suffix = rest.partition(".")
if not layer_index.isdigit() or not suffix:
return out
base = f"model.layers.{layer_index}"
if suffix == "self_attn.in_proj_weight":
q, k, v = loaded_weight.chunk(3, dim=0)
pname = f"{base}.self_attn.qkv_proj.weight"
self._load_shard(pname, q, "q", params)
self._load_shard(pname, k, "k", params)
self._load_shard(pname, v, "v", params)
out.add(pname)
elif suffix == "self_attn.out_proj.weight":
self._load_direct(f"{base}.self_attn.o_proj.weight", loaded_weight, params, out)
elif suffix == "gating.linear_in.weight":
gate, up = loaded_weight.chunk(2, dim=0)
pname = f"{base}.mlp.gate_up_proj.weight"
self._load_shard(pname, gate, 0, params)
self._load_shard(pname, up, 1, params)
out.add(pname)
elif suffix == "gating.linear_out.weight":
self._load_direct(f"{base}.mlp.down_proj.weight", loaded_weight, params, out)
elif suffix == "norm1.alpha":
self._load_direct(f"{base}.input_layernorm.weight", loaded_weight.squeeze(), params, out)
elif suffix == "norm2.alpha":
self._load_direct(f"{base}.post_attention_layernorm.weight", loaded_weight.squeeze(), params, out)
return out
@staticmethod
def _load_direct(
name: str,
loaded_weight: torch.Tensor,
params: dict[str, nn.Parameter],
loaded: set[str],
) -> None:
if name not in params:
return
param = params[name]
weight_loader = getattr(param, "weight_loader", default_weight_loader)
weight_loader(param, loaded_weight)
loaded.add(name)
@staticmethod
def _load_shard(
name: str,
loaded_weight: torch.Tensor,
shard_id: str | int,
params: dict[str, nn.Parameter],
) -> None:
if name not in params:
return
param = params[name]
param.weight_loader(param, loaded_weight, shard_id)
@@ -0,0 +1,257 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Streaming Helium temporal transformer for PersonaPlex (moshi-free, plain torch).
Frame-clocked duplex needs a stateful per-frame forward: one 80 ms step consumes
one summed frame embedding and returns the temporal hidden + text logits, with a
sliding-window KV carried across the conversation. vLLM's engine path serves the
offline/staged flavor of this model (``modeling_helium.HeliumForCausalLM``); this
module is the REAL-TIME flavor: no engine context, a fixed-capacity ring KV, and
static shapes so the whole step is CUDA-graphable.
Semantics mirror the Moshi streaming transformer exactly (MIT; the reference for
the ``nvidia/personaplex-7b-v1`` checkpoint), op for op, so greedy decoding is
bit-comparable against recorded reference outputs:
- fp32 RMSNorm with ``alpha`` weights (norm1/norm2/out_norm)
- fused in-proj attention, interleaved (non-neox) RoPE applied at the ABSOLUTE
offset in fp32, relative-position causal mask over a ring KV with
``context``-window truncation
- SiLU gating MLP with fused ``linear_in`` (gate/up) and ``linear_out``
- ``transformer_out`` is the POST-``out_norm`` hidden; ``text_logits`` is
``text_linear`` of that hidden
Weights load directly from the moshi checkpoint's fused layout
(``transformer.layers.{i}.self_attn.in_proj_weight`` etc.), keeping the exact
computation order of the reference.
Per-slot elastic recycle (concurrent serving) uses the same per-row
``start_offset`` masking verified for the moshi-hosted path: writes stay uniform
because every slot ticks in lockstep; ``reset_slot`` only moves that row's
valid-window start. The LM-specific "+1 sacrifice tick" bump is the CALLER's
job (see the fullduplex PersonaPlex stage0), matching the split introduced when
the +1 was found to be wrong for codec streams.
"""
from __future__ import annotations
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
from vllm_omni.model_executor.models.personaplex.personaplex_depformer import _rms_norm_f32
def _apply_rope(q: torch.Tensor, k: torch.Tensor, offset: torch.Tensor, max_period: float = 10_000.0):
"""Interleaved RoPE at absolute ``offset``, fp32 rotation (moshi ``apply_rope``)."""
B, H, T, D = q.shape
ds = torch.arange(D // 2, device=q.device, dtype=torch.float32)
freqs = torch.exp(ds * (-math.log(max_period) * 2 / D))
ts = offset.float() + torch.arange(T, device=q.device, dtype=torch.float32)
ts = ts.view(1, -1, 1)
dims = q.shape[:-1]
q = q.view(*dims, D // 2, 2)
k = k.view(*dims, D // 2, 2)
qr, qi = q[..., 0].float(), q[..., 1].float()
kr, ki = k[..., 0].float(), k[..., 1].float()
rotr = torch.cos(freqs * ts)
roti = torch.sin(freqs * ts)
qor = qr * rotr - qi * roti
qoi = qr * roti + qi * rotr
kor = kr * rotr - ki * roti
koi = kr * roti + ki * rotr
dtype = q.dtype
qo = torch.stack([qor.to(dtype), qoi.to(dtype)], dim=-1)
ko = torch.stack([kor.to(dtype), koi.to(dtype)], dim=-1)
return qo.view(*dims, D), ko.view(*dims, D)
class _RingKV:
"""Fixed-capacity KV ring with per-row valid-window start (elastic recycle).
Uniform writes (all rows tick together) + a per-row mask via absolute
positions; with ``start_offset == 0`` everywhere this is the plain streaming
cache. All shapes static -> CUDA-graph safe.
"""
def __init__(self, batch_size: int, num_heads: int, dim_per_head: int, capacity: int, device, dtype):
self.capacity = capacity
self.cache = torch.zeros((2, batch_size, num_heads, capacity, dim_per_head), device=device, dtype=dtype)
self.end_offset = torch.zeros(1, device=device, dtype=torch.long)
self.start_offset = torch.zeros(batch_size, device=device, dtype=torch.long)
def reset(self) -> None:
self.end_offset.zero_()
self.start_offset.zero_()
def reset_slot(self, b: int) -> None:
# Mask everything written so far for row b; the row's next write is its
# first visible entry. (LM sacrifice-tick +1 is applied by the caller.)
self.start_offset[b] = self.end_offset.clone()
def bump_slot_start(self, b: int) -> None:
self.start_offset[b] += 1
def complete(self, k: torch.Tensor, v: torch.Tensor):
B, H, T, D = k.shape
indexes = torch.arange(T, device=self.end_offset.device, dtype=self.end_offset.dtype) + self.end_offset
indexes = indexes % self.capacity
self.cache[0].index_copy_(2, indexes, k)
self.cache[1].index_copy_(2, indexes, v)
self.end_offset.add_(T)
idx = torch.arange(self.capacity, device=self.end_offset.device, dtype=torch.long)
invalid = idx >= self.end_offset
end_index = self.end_offset % self.capacity
delta = idx - end_index
# `delta <= 0` (not `< 0`) is moshi's exact convention (transformer.py
# RingKVCache.complete). It labels the just-past-newest slot as the future
# write position, so once the ring has wrapped the single oldest in-window
# cell is excluded and the effective window is capacity-1. This is inherited
# verbatim from the reference and only shows after the window fills (Helium
# ~3000 frames / 240 s); it costs one frame out of thousands. Do NOT change
# this to `< 0`: it would diverge from moshi and break greedy bit-parity.
positions = torch.where(delta <= 0, self.end_offset + delta, self.end_offset + delta - self.capacity)
positions = torch.where(invalid, torch.full_like(positions, -1), positions)
positions = positions.view(1, -1) # [1, capacity]
below = positions < self.start_offset.view(-1, 1) # [B, capacity]
positions = torch.where(below, torch.full_like(positions, -1), positions)
return self.cache[0], self.cache[1], positions
class _TemporalLayer(nn.Module):
"""One Helium decoder layer in the checkpoint's fused layout."""
def __init__(self, dim: int, num_heads: int, hidden: int) -> None:
super().__init__()
self.dim = dim
self.num_heads = num_heads
self.head_dim = dim // num_heads
self.in_proj_weight = nn.Parameter(torch.empty(3 * dim, dim))
self.out_proj_weight = nn.Parameter(torch.empty(dim, dim))
self.gating_in = nn.Parameter(torch.empty(2 * hidden, dim))
self.gating_out = nn.Parameter(torch.empty(dim, hidden))
self.norm1_alpha = nn.Parameter(torch.ones(1, 1, dim))
self.norm2_alpha = nn.Parameter(torch.ones(1, 1, dim))
def forward(self, x: torch.Tensor, kv: _RingKV, offset: torch.Tensor, context: int) -> torch.Tensor:
B, T, _ = x.shape
h = _rms_norm_f32(x, self.norm1_alpha, 1e-8)
qkv = F.linear(h, self.in_proj_weight)
# Layout mirrors moshi's einops "b t (p h d) -> p b h t d" exactly, so the
# downstream kernels see identical strides (bit-level replay agreement).
qkv = qkv.view(B, T, 3, self.num_heads, self.head_dim).permute(2, 0, 3, 1, 4)
q, k, v = qkv[0], qkv[1], qkv[2]
q, k = _apply_rope(q, k, offset)
keys, values, pos_k = kv.complete(k, v)
pos_k = pos_k.view(pos_k.shape[0], 1, pos_k.shape[1]) # [B, 1, cap]
pos_q = offset + torch.arange(T, device=q.device, dtype=torch.long).view(1, -1, 1)
delta = pos_q - pos_k
attn_bias = (pos_k >= 0) & (delta >= 0) & (delta < context)
attn_bias = attn_bias.unsqueeze(1) # [B, 1, T, cap]
attn = F.scaled_dot_product_attention(q, keys, values, attn_bias, dropout_p=0.0)
attn = attn.transpose(1, 2).reshape(B, T, self.dim)
x = x + F.linear(attn, self.out_proj_weight)
h = _rms_norm_f32(x, self.norm2_alpha, 1e-8)
a, b = F.linear(h, self.gating_in).chunk(2, dim=-1)
return x + F.linear(F.silu(a) * b, self.gating_out)
class PersonaPlexTemporalStreaming(nn.Module):
"""The 7B Helium temporal backbone as a stateful per-frame stepper.
``step(frame_embedding [B, 1, dim]) -> (transformer_out [B, 1, dim],
text_logits [B, 1, 1, text_card])``, matching moshi's
``LMModel.forward_embeddings`` contract so the existing native depformer and
input embeddings compose unchanged.
"""
def __init__(
self,
dim: int = 4096,
num_layers: int = 32,
num_heads: int = 32,
hidden: int = 11264,
context: int = 3000,
text_card: int = 32000,
max_period: float = 10_000.0,
) -> None:
super().__init__()
self.dim = dim
self.context = context
self.max_period = max_period
self.layers = nn.ModuleList([_TemporalLayer(dim, num_heads, hidden) for _ in range(num_layers)])
self.out_norm_alpha = nn.Parameter(torch.ones(1, 1, dim))
self.text_linear = nn.Parameter(torch.empty(text_card, dim))
self._kv: list[_RingKV] | None = None
self._offset: torch.Tensor | None = None
# -- streaming state ----------------------------------------------------
def streaming_init(self, batch_size: int) -> None:
p = next(self.parameters())
heads = self.layers[0].num_heads
head_dim = self.layers[0].head_dim
# Capacity = context window (the mask truncates at `context` anyway).
self._kv = [_RingKV(batch_size, heads, head_dim, self.context, p.device, p.dtype) for _ in self.layers]
self._offset = torch.zeros(1, device=p.device, dtype=torch.long)
def reset_streaming(self) -> None:
assert self._kv is not None, "call streaming_init first"
for kv in self._kv:
kv.reset()
self._offset.zero_()
def reset_slot(self, b: int) -> None:
assert self._kv is not None
for kv in self._kv:
kv.reset_slot(b)
def bump_slot_start(self, b: int) -> None:
"""LM sacrifice-tick semantics: mask the one spurious post-recycle write."""
assert self._kv is not None
for kv in self._kv:
kv.bump_slot_start(b)
# -- per-frame step -------------------------------------------------------
@torch.no_grad()
def step(self, frame_embedding: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
assert self._kv is not None, "call streaming_init first"
x = frame_embedding
for layer, kv in zip(self.layers, self._kv):
x = layer(x, kv, self._offset, self.context)
self._offset.add_(x.shape[1])
out = _rms_norm_f32(x, self.out_norm_alpha, 1e-8)
text_logits = F.linear(out, self.text_linear)
return out, text_logits[:, None]
# -- weights --------------------------------------------------------------
def load_weights(self, state_dict: dict[str, torch.Tensor]) -> int:
"""Load from the moshi checkpoint layout (fused, exact tensor reuse)."""
loaded = 0
with torch.no_grad():
for i, layer in enumerate(self.layers):
base = f"transformer.layers.{i}"
pairs = [
(layer.in_proj_weight, f"{base}.self_attn.in_proj_weight"),
(layer.out_proj_weight, f"{base}.self_attn.out_proj.weight"),
(layer.gating_in, f"{base}.gating.linear_in.weight"),
(layer.gating_out, f"{base}.gating.linear_out.weight"),
(layer.norm1_alpha, f"{base}.norm1.alpha"),
(layer.norm2_alpha, f"{base}.norm2.alpha"),
]
for param, name in pairs:
src = state_dict[name]
param.data.copy_(src.reshape(param.shape).to(param.dtype))
loaded += 1
self.out_norm_alpha.data.copy_(state_dict["out_norm.alpha"].reshape(self.out_norm_alpha.shape))
self.text_linear.data.copy_(state_dict["text_linear.weight"].to(self.text_linear.dtype))
loaded += 2
return loaded
@@ -0,0 +1,75 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""PersonaPlex pipeline: Talker (AR decode -> Mimi codebooks) -> Code2Wav (codebooks -> 24 kHz PCM).
PersonaPlex is a Moshi finetune (full-duplex speech-to-speech). For offline/batch
runs it is served as a 2-stage vllm-omni audio->audio pipeline, reusing the
Qwen3-TTS staged topology:
* Stage 0 (``personaplex``) is the AR talker: the Helium temporal transformer
plus the depformer (both built by the lead). It emits the per-frame audio
codebooks as latents.
* Stage 1 (``code2wav``) wraps the external Mimi codec and decodes the active
codebooks (``cb 0..7``) into PCM.
The inter-stage input processors named below live in
``vllm_omni.model_executor.stage_input_processors.personaplex`` and are part of
the talker<->code2wav seam; they are built by the lead alongside the talker.
"""
from vllm_omni.config.stage_config import (
PipelineConfig,
StageExecutionType,
StagePipelineConfig,
)
# Talker<->Code2Wav input processors (built by lead; coupled to talker output).
_PROC = "vllm_omni.model_executor.stage_input_processors.personaplex"
PERSONAPLEX_PIPELINE = PipelineConfig(
model_type="personaplex",
# Pipeline-level default; the code2wav stage overrides per-stage below.
model_arch="PersonaPlexTalkerForConditionalGeneration",
default_deploy_config_name="personaplex.yaml",
duplex_runtime_extension=(
"vllm_omni.experimental.fullduplex.personaplex.runtime_extension.PersonaPlexDuplexRuntimeExtension"
),
duplex_serving_adapter=(
"vllm_omni.experimental.fullduplex.personaplex.serving_adapter.PersonaPlexServingRuntimeAdapter"
),
duplex_control_enabled=True,
stages=(
StagePipelineConfig(
stage_id=0,
model_stage="personaplex",
execution_type=StageExecutionType.LLM_AR,
input_sources=(),
owns_tokenizer=True,
engine_output_type="latent",
# built by lead: talker emits a 17-row Mimi token stack per frame;
# these processors keep the PCM-bearing rows (cb 0..7) and flatten
# them codebook-major for the Code2Wav stage.
async_chunk_process_next_stage_input_func=(f"{_PROC}.talker2code2wav_async_chunk"),
custom_process_next_stage_input_func=f"{_PROC}.talker2code2wav_full_payload",
sampling_constraints={
"detokenize": False,
},
),
StagePipelineConfig(
stage_id=1,
model_stage="code2wav",
execution_type=StageExecutionType.LLM_GENERATION,
input_sources=(0,),
final_output=True,
final_output_type="audio",
engine_output_type="audio",
model_arch="PersonaPlexCode2Wav",
# In sync (non-async-chunk) mode the only pre-stage input proc is a
# length-only placeholder; the bulk codec payload ships via the
# worker connector from stage 0's full-payload producer.
# built by lead.
sync_process_input_func=f"{_PROC}.talker2code2wav_token_only",
sampling_constraints={"detokenize": True},
),
),
)
@@ -164,6 +164,18 @@ _OMNI_MODELS = {
"qwen3_tts_code2wav",
"Qwen3TTSCode2Wav",
),
## personaplex (Moshi finetune, full-duplex S2S; 2-stage audio->audio offline pipeline)
# built by lead: personaplex_talker module + PersonaPlexTalkerForConditionalGeneration.
"PersonaPlexTalkerForConditionalGeneration": (
"personaplex",
"personaplex_talker",
"PersonaPlexTalkerForConditionalGeneration",
),
"PersonaPlexCode2Wav": (
"personaplex",
"personaplex_code2wav",
"PersonaPlexCode2Wav",
),
## higgs-audio v2
"HiggsAudioV2ForConditionalGeneration": (
"higgs_audio_v2",
@@ -0,0 +1,248 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Talker -> Code2Wav input processors for PersonaPlex.
The talker (stage 0) emits, per frame, the ``dep_q`` depformer audio codes under
``("codes","audio")``. Only the leading ``num_active_codebooks`` (the agent's
``cb 0..7``) are decoded to PCM by Mimi; the trailing codebooks are the user
stream and are not vocoded. These processors take the accumulated per-frame agent
codes ``[F, dep_q]``, keep ``cb 0..7``, and flatten them codebook-major
(``[8 * F]``) — the exact layout :class:`PersonaPlexCode2Wav` consumes.
Mirrors the Qwen3-TTS processors (sync ``full_payload`` + ``token_only``; an
async-chunk variant for the streaming path), but with PersonaPlex's agent-codebook
slice instead of Qwen3-TTS's residual layout.
"""
from __future__ import annotations
from typing import Any
import torch
from vllm_omni.data_entry_keys import (
CodesStruct,
MetaStruct,
OmniPayloadStruct,
)
_NUM_ACTIVE_CODEBOOKS = 8 # agent cb 0..7 (the PCM-bearing rows)
def _empty_finished_payload() -> OmniPayloadStruct:
return OmniPayloadStruct(
codes=CodesStruct(audio=torch.empty(0, dtype=torch.long)),
meta=MetaStruct(finished=torch.tensor(True, dtype=torch.bool)),
)
def _agent_codes_to_codebook_major(audio: torch.Tensor) -> torch.Tensor:
"""``[F, dep_q]`` raw depformer agent codes -> de-delayed flat codebook-major.
The talker emits the raw per-frame depformer codes ``gen[t]`` (cb 0..7). Mimi
needs them ACOUSTICALLY DE-DELAYED to a common time step (Moshi agent delays
``[0, 1, 1, 1, 1, 1, 1, 1]``): acoustic frame t's cb_k is predicted at step
``t + delay[k]``, so output frame t = ``[gen[t][0], gen[t+1][1:8]]`` (cb0 from
the current step, cb1..7 from the NEXT step, since the delayed codebooks lag).
Without this the codebooks are misaligned by one frame and Mimi decodes garble.
The last frame has no successor for cb1..7, so it is dropped (the delay warmup).
"""
if audio.ndim != 2 or audio.shape[0] < 2:
return torch.empty(0, dtype=torch.long)
audio = audio.to(torch.long)
k = min(_NUM_ACTIVE_CODEBOOKS, int(audio.shape[1]))
agent = audio[:, :k]
valid = (agent >= 0).all(dim=1)
agent = agent[valid]
if agent.shape[0] < 2:
return torch.empty(0, dtype=torch.long)
# De-delay: cb0 from frame t, cb1..7 from frame t+1 (drop the last frame).
cb0 = agent[:-1, 0:1] # [F-1, 1]
cb_rest = agent[1:, 1:k] # [F-1, k-1]
dd = torch.cat([cb0, cb_rest], dim=1) # [F-1, k] de-delayed
# [F-1, k] -> [k, F-1] -> flat [k * (F-1)] (codebook-major), as Code2Wav expects.
return dd.transpose(0, 1).contiguous().reshape(-1)
def talker2code2wav_token_only(
source_outputs: list,
prompt: Any = None,
_requires_multimodal_data: bool = False,
) -> list:
"""Sync ``process_engine_inputs``: build the code2wav placeholder inputs.
Returns one :class:`OmniTokensPrompt` per finished talker request, with
``prompt_token_ids`` sized to the flat codebook-major codec length
(``num_active_codebooks * num_agent_frames``). The actual codec ids are
delivered via the worker connector payload from ``talker2code2wav_full_payload``.
"""
from vllm_omni.inputs.data import OmniTokensPrompt
del prompt, _requires_multimodal_data
inputs: list = []
for talker_output in source_outputs:
if not getattr(talker_output, "finished", False):
continue
output = talker_output.outputs[0]
# The per-request output is the "latent" (engine_output_type="latent"); the
# codes ship via the connector full_payload. Size the placeholder from the
# generated token count (one AR token == one Mimi frame). prompt was 1 frame.
token_ids = getattr(output, "cumulative_token_ids", None) or getattr(output, "token_ids", None) or []
n_frames = max(len(token_ids) - 1, 0)
prompt_len = _NUM_ACTIVE_CODEBOOKS * n_frames
inputs.append(
OmniTokensPrompt(
prompt_token_ids=[0] * prompt_len,
additional_information=None,
multi_modal_data=None,
mm_processor_kwargs=None,
)
)
return inputs
def talker2code2wav_full_payload(
transfer_manager: Any = None,
pooling_output: Any = None,
request: Any = None,
is_finished: bool = False,
**kwargs: Any,
) -> OmniPayloadStruct:
"""Producer: collect the talker's accumulated agent codes -> Code2Wav input.
Called by the connector with (transfer_manager, pooling_output, request,
is_finished). Prefer the request's additional_information payload under
("codes","audio") (talker_mtp_output_key), while accepting pooling_output
and the legacy multimodal_output keyword as compatibility fallbacks.
"""
del transfer_manager, is_finished
def _codes_from(src: Any) -> torch.Tensor | None:
if not isinstance(src, dict):
return None
nested = src.get("codes")
audio = nested.get("audio") if isinstance(nested, dict) else None
return audio if audio is not None else src.get("codes.audio")
audio = None
for source in (
getattr(request, "additional_information", None),
getattr(request, "additional_information_cpu", None),
pooling_output,
# Temporary compatibility shim for the two producer call contracts.
# Remove after https://github.com/vllm-project/vllm-omni/issues/4872
# provides validated full-payload and async-chunk processor protocols.
kwargs.get("multimodal_output"),
):
audio = _codes_from(source)
if audio is not None:
break
if not isinstance(audio, torch.Tensor) or audio.numel() == 0:
return _empty_finished_payload()
flat = _agent_codes_to_codebook_major(audio)
if flat.numel() == 0:
return _empty_finished_payload()
return OmniPayloadStruct(
codes=CodesStruct(audio=flat),
meta=MetaStruct(finished=torch.tensor(True, dtype=torch.bool)),
)
def talker2code2wav_async_chunk(
transfer_manager: Any,
multimodal_output: Any,
request: Any,
is_finished: bool = False,
) -> OmniPayloadStruct | None:
"""Streaming: accumulate per-frame agent codes, emit a codebook-major chunk.
Minimal fixed-chunk variant (no left-context / ref-code complexity, which
PersonaPlex does not use). Frames are buffered on the transfer manager until a
chunk's worth is ready (or the request finishes), then flushed.
"""
request_id = getattr(request, "external_req_id", getattr(request, "request_id", "?"))
# The adapter passes ``is_finished=True`` for both a resumable segment
# boundary and the terminal request boundary. PersonaPlex must preserve the
# delayed cb1..7 tail across the former; only a non-resumable stop flushes
# the stream.
finished = bool(is_finished and not getattr(request, "resumable", False))
request_payload = getattr(transfer_manager, "request_payload", None)
if not isinstance(request_payload, dict):
request_payload = {}
transfer_manager.request_payload = request_payload
state = request_payload.setdefault(request_id, {})
frames = state.setdefault("personaplex_frames", [])
# Codes live in the server-side request's additional_information under
# ("codes","audio") (talker_mtp_output_key), not in multimodal_output (latent).
def _codes_from(src: Any) -> torch.Tensor | None:
if isinstance(src, dict):
nested = src.get("codes")
a = nested.get("audio") if isinstance(nested, dict) else None
return a if a is not None else src.get("codes.audio")
return None
# Explicit None fallback: `a or b` would evaluate bool(a) on a multi-element
# Tensor and raise "Boolean value of Tensor ... is ambiguous".
audio = _codes_from(getattr(request, "additional_information", None))
if audio is None:
audio = _codes_from(multimodal_output)
if isinstance(audio, torch.Tensor) and audio.numel() > 0:
a = audio if audio.ndim == 2 else audio.reshape(1, -1)
frames.append(a[-1].to(torch.long).cpu()) # latest frame's codes
connector = getattr(transfer_manager, "connector", None)
raw_cfg = getattr(connector, "config", {}) or {}
cfg = raw_cfg.get("extra", raw_cfg) if isinstance(raw_cfg, dict) else {}
chunk = int(cfg.get("codec_chunk_frames", 25))
initial_chunk = int(cfg.get("initial_codec_chunk_frames") or 0)
if chunk <= 0 or initial_chunk < 0:
raise ValueError(
"PersonaPlex codec chunk sizes must be positive/non-negative: "
f"codec_chunk_frames={chunk}, initial_codec_chunk_frames={initial_chunk}"
)
target_frames = initial_chunk if not state.get("personaplex_emitted") and initial_chunk > 0 else chunk
# De-delay needs one successor raw frame: N output acoustic frames require
# N + 1 raw depformer rows.
available_frames = max(0, len(frames) - 1)
if available_frames < target_frames and not finished:
# Each full-duplex input frame is a resumable stage-0 segment. Returning
# None would make the generic chunk adapter synthesize a
# segment-finished marker, wake Code2Wav with its one-token placeholder,
# and discard the buffered de-delay tail. Explicitly keep this transport
# chunk non-terminal until enough successor frames exist.
pending = torch.tensor(False, dtype=torch.bool)
return OmniPayloadStruct(
meta=MetaStruct(
finished=pending,
is_segment_finished=pending,
)
)
emit_frames = available_frames if finished else target_frames
if emit_frames <= 0:
if finished:
request_payload.pop(request_id, None)
return _empty_finished_payload()
return None
stacked = torch.stack(frames[: emit_frames + 1], dim=0) # [F+1, dep_q]
flat = _agent_codes_to_codebook_major(stacked)
if finished:
request_payload.pop(request_id, None)
else:
# Row ``emit_frames`` is the successor used by the last emitted frame
# and the cb0 source for the next frame.
state["personaplex_frames"] = frames[emit_frames:]
state["personaplex_emitted"] = True
return OmniPayloadStruct(
codes=CodesStruct(audio=flat),
meta=MetaStruct(finished=torch.tensor(bool(finished), dtype=torch.bool)),
)
__all__ = [
"talker2code2wav_token_only",
"talker2code2wav_full_payload",
"talker2code2wav_async_chunk",
]
+123
View File
@@ -1516,6 +1516,120 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
return None
return hidden_states_cpu[start:end]
def _run_post_sample_talker_mtp(
self,
*,
req_ids: list[str],
valid_sampled_token_ids: list[list[int]],
sampled_token_ids: torch.Tensor,
invalid_req_indices: list[int],
sample_hidden_states: torch.Tensor,
multimodal_outputs: Any,
) -> Any:
"""Run an opt-in talker MTP with the token sampled in this step.
Some frame-synchronous talkers need the current temporal hidden state
and its newly sampled text token to produce the same frame's codec
codes. A resumable ``max_tokens=1`` segment has no later decode
preprocess in which to do that work, so those models expose
``post_sample_talker_mtp`` instead.
"""
model = getattr(self, "model", None)
hook = getattr(model, "post_sample_talker_mtp", None)
if not callable(hook):
return multimodal_outputs
selected_indices: list[int] = []
selected_req_ids: list[str] = []
selected_req_infos: list[dict[str, Any]] = []
duplex_indices: list[int] = []
invalid_indices = set(invalid_req_indices)
use_async_scheduling = bool(getattr(self, "use_async_scheduling", False))
for idx, req_id in enumerate(req_ids):
req_info = self.model_intermediate_buffer.get(req_id)
duplex = req_info.get("duplex") if isinstance(req_info, dict) else None
if not isinstance(duplex, dict) or duplex.get("data_plane") is not True:
continue
duplex_indices.append(idx)
if use_async_scheduling:
if idx in invalid_indices:
continue
else:
token_ids = valid_sampled_token_ids[idx] if idx < len(valid_sampled_token_ids) else []
if not token_ids:
continue
if len(token_ids) != 1:
raise ValueError(
"post_sample_talker_mtp requires exactly one sampled token "
f"per request, got {len(token_ids)} for {req_id}"
)
selected_indices.append(idx)
selected_req_ids.append(req_id)
selected_req_infos.append(req_info)
if not selected_indices:
return multimodal_outputs
if sampled_token_ids.ndim != 2 or sampled_token_ids.shape[0] < len(req_ids):
raise ValueError(
"post_sample_talker_mtp sampled-token rows do not match requests: "
f"shape={tuple(sampled_token_ids.shape)}, requests={len(req_ids)}"
)
if sampled_token_ids.shape[1] != 1:
raise ValueError(
"post_sample_talker_mtp does not support speculative token rows: "
f"shape={tuple(sampled_token_ids.shape)}"
)
empty_audio = torch.empty(0, dtype=torch.long, device=sample_hidden_states.device)
merged = dict(multimodal_outputs) if isinstance(multimodal_outputs, dict) else {}
existing_codes = merged.get("codes")
codes_payload = dict(existing_codes) if isinstance(existing_codes, dict) else {}
existing_audio = codes_payload.get("audio")
if isinstance(existing_audio, list) and len(existing_audio) == len(req_ids):
per_request_audio = list(existing_audio)
elif (
isinstance(existing_audio, torch.Tensor)
and existing_audio.ndim > 0
and existing_audio.shape[0] == len(req_ids)
):
per_request_audio = [existing_audio[idx : idx + 1] for idx in range(len(req_ids))]
else:
per_request_audio = [empty_audio for _ in req_ids]
for idx in duplex_indices:
per_request_audio[idx] = empty_audio
indices = torch.tensor(
selected_indices,
dtype=torch.long,
device=sample_hidden_states.device,
)
input_ids = sampled_token_ids.index_select(0, indices).reshape(-1).to(torch.long)
codes = hook(
input_ids=input_ids,
hidden_states=sample_hidden_states.index_select(0, indices),
req_ids=selected_req_ids,
req_infos=selected_req_infos,
)
if not isinstance(codes, torch.Tensor) or codes.ndim != 2:
raise TypeError(f"post_sample_talker_mtp must return a rank-2 Tensor, got {type(codes).__name__}")
if codes.shape[0] != len(selected_req_ids):
raise ValueError(
f"post_sample_talker_mtp row count does not match requests: {codes.shape[0]} != {len(selected_req_ids)}"
)
for row, (idx, req_id) in enumerate(zip(selected_indices, selected_req_ids, strict=True)):
request_codes = codes[row : row + 1]
per_request_audio[idx] = request_codes
self._update_intermediate_buffer(
req_id,
{"codes": {"audio": request_codes}},
)
# Empty rows are intentional: they suppress replay of the previous
# frame while a chunked prefill has not sampled a token yet.
codes_payload["audio"] = per_request_audio
merged["codes"] = codes_payload
return merged
def _build_multimodal_outputs(
self,
per_req_payloads: list[dict[str, object] | None] | None,
@@ -2036,6 +2150,15 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
scheduler_output.total_num_scheduled_tokens,
)
multimodal_outputs = self._run_post_sample_talker_mtp(
req_ids=req_ids_output_copy,
valid_sampled_token_ids=valid_sampled_token_ids,
sampled_token_ids=sampler_output.sampled_token_ids,
invalid_req_indices=invalid_req_indices,
sample_hidden_states=sample_hidden_states,
multimodal_outputs=multimodal_outputs,
)
if propose_drafts_after_bookkeeping:
# ngram and other speculative decoding methods use the sampled
# tokens on the CPU, so they are run after bookkeeping.