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>
263 lines
9.3 KiB
Python
263 lines
9.3 KiB
Python
# 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
|