# 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://: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()