Signed-off-by: chickeyton <ngton2014@gmail.com> Signed-off-by: Gao Han <hgaoaf@connect.ust.hk> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Gao Han <hgaoaf@connect.ust.hk>
12 KiB
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:
/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 that accompanied the
original PR was demo-only and has been removed from the tree; the unified
engine path above is the only serving surface. It was never evidence that the
unified engine path works.
Why configuration-only enablement was invalid
The staged pipeline as shipped by the original PR was explicitly turn based.
Its Talker read pplex_user_codes, pplex_prefill_text, and
pplex_silence_codes, but no production staged input path wrote those fields.
The voice prompt, persona prefill, and streaming Mimi state lived only in the
standalone PersonaPlexEngine (demo-only, since removed).
Setting only the following fields would therefore advertise an endpoint whose model never receives the live microphone stream:
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
.ptvoice prompts and a session persona; - greedy text and depformer sampling, matching the current PersonaPlex port;
/v1/realtime?duplex=1(the wire vocabulary is catalogued in the Realtime Duplex API serving guide);- the public client preset
vllm_omni.clients.personaplex.create_duplex_session_config()(24 kHzpcm_f32leinput format, voice prompt, persona) as the canonical session-config source forDuplexClientconsumers; - 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.createsemantics 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_promptandinstructions; - 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:
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:
{
"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. The generic projector turns each native result into the Realtime
response.audio.delta / response.audio_transcript.delta pair (and
response.output_text.delta for text) under one response_id; the full
mapping is the name map in the
Realtime Duplex API serving guide.
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. PersonaPlex advertises supports_barge_in=false and
supports_session_resume=false, so barge_in, turn_detection.server_vad,
and session.resume are rejected on this model.
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_idmust 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,
which followed the same exception-safe rule, have been removed from 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:
- default
personaplex.yamlreaches ready without a local eager override; /healthreturns 200;/v1/realtime?duplex=1reportsmodel_native_duplex,chunk_period_ms=80, and two-session admission;- paced 24 kHz PCM appends produce finite, non-silent 24 kHz audio deltas and text deltas;
- microphone input continues during assistant output without cancelling the scheduler request;
- two paced sessions simultaneously produce independent non-empty audio, and
a third session is rejected with
resource_exhausted; - 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;
- malformed input returns one typed error and does not poison the next append;
- all focused unit tests and
git diff --checkpass.
Audio that is empty, all zero, non-finite, or only a protocol listen event is
not a successful end-to-end result.